Uploading PDFs and images once with the Claude Files API and reusing them across multi-turn conversations
When building a multi-turn chatbot, you eventually run into a situation like this: a user is asking ten questions about a 50-page contract, and every single request is carrying the entire PDF Base64-encoded in the payload. Each request weighs several megabytes, latency climbs, and the CPU stays busy with encoding. My initial reaction was "token costs are the main thing anyway, so just tolerate the network overhead" — but that changed once session concurrency started rising.
The Files API, released in beta by Anthropic in April 2025, addresses this problem head-on. Upload a file once, and every subsequent request can reference the returned file_id without retransmitting the file body. There is one important caveat, though: the Files API does not automatically lower your token costs. Even when Claude receives a file_id, it processes the original content internally and charges based on token count. To control input-token costs, you need to use Prompt Caching alongside it. Missing this distinction leads to misaligned expectations, so it is worth establishing upfront.
This post covers the operational structure of the Files API, integration patterns with the Python SDK, and how to combine it with Prompt Caching — all from a practical standpoint.
Why the Files API Is Needed
The Cost of Sending the Full File on Every Request
The conventional way to handle PDFs with the Claude API was to embed the Base64-encoded file body directly in a message content block. That is fine for one-off requests. In multi-turn conversations or multi-user scenarios, however, the story changes.
If ten users each ask ten questions about the same 50-page document, the same file is transmitted a hundred times. Payload size, encoding CPU, and round-trip latency all multiply together.
What the Files API Changes
After adopting the Files API, the flow looks like this:
The file body is transmitted only once. All subsequent requests include just the file_id string. Network overhead and Base64 encoding costs are eliminated. Because a file_id can be referenced anywhere within an organization, this is especially useful when multiple user sessions share the same file.
Basic Usage
Supported File Types
As of August 2026 (beta), the major formats supported by the Files API are as follows:
| Format | MIME Type | Notes |
|---|---|---|
| application/pdf | Referenced as a document block | |
| Plain text | text/plain | .txt |
| Markdown | text/markdown | |
| Images | image/jpeg, image/png, image/gif, image/webp | Referenced as an image block |
| Source code | text/plain, etc. | Language-specific extensions |
Office formats such as DOCX and XLSX are not directly processed by Claude in any mode, so they must be converted to PDF before uploading. Per-file size limits and organization storage quotas vary by plan — check the official Files API documentation for the latest values.
Uploading with the Python SDK and Reusing in Multi-Turn Conversations
All requests require a beta header. In the Python SDK this is handled via the betas=["files-api-2025-04-14"] parameter.
import anthropic
client = anthropic.Anthropic() # ANTHROPIC_API_KEY environment variable
# Step 1: Upload the file (once)
with open("contract.pdf", "rb") as f:
file_obj = client.beta.files.upload(
file=("contract.pdf", f, "application/pdf"),
)
file_id = file_obj.id
print(f"Upload complete: {file_id}")
def extract_text(response) -> str:
# content block order can vary, so select by type
return next(b.text for b in response.content if b.type == "text")
# Step 2: Multi-turn conversation — include the document block only on the first turn, then add text only
conversation_history = []
def ask(question: str) -> str:
if not conversation_history:
user_content = [
{
"type": "document",
"source": {"type": "file", "file_id": file_id},
"title": "Contract",
"citations": {"enabled": True},
},
{"type": "text", "text": question},
]
else:
user_content = [{"type": "text", "text": question}]
conversation_history.append({"role": "user", "content": user_content})
response = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=conversation_history,
betas=["files-api-2025-04-14"],
)
answer = extract_text(response)
conversation_history.append({"role": "assistant", "content": answer})
return answer
print(ask("What is the contract term in this agreement?"))
print(ask("What are the termination conditions?"))
print(ask("Summarize the penalty clause."))There is a common misconception here. Putting only text in user_content from the second turn onward does not mean "subsequent requests send no file information at all." The conversation_history still contains the first turn's document block (including the file_id), and that entire history is sent to the API on every request. The actual source of the lighter network footprint is that "the file body bytes are replaced by a file_id string in the history." The file body itself is not retransmitted — the history does not disappear.
Referencing Images
Images follow the same pattern using "type": "image".
with open("product_screenshot.png", "rb") as f:
img_obj = client.beta.files.upload(
file=("product_screenshot.png", f, "image/png"),
)
img_id = img_obj.id
response = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "file", "file_id": img_id},
},
{"type": "text", "text": "Tell me what could be improved in this UI."},
],
}
],
betas=["files-api-2025-04-14"],
)
print(next(b.text for b in response.content if b.type == "text"))File Lifecycle Management
APIs for listing and deleting uploaded files are also provided.
# List files
files = client.beta.files.list()
for f in files.data:
print(f.id, f.filename, f.created_at)
# Delete a file
client.beta.files.delete(file_id)It is good practice to run a routine that periodically cleans up unnecessary files to manage storage quotas. For the latest information on retention periods or automatic expiration policies, consult the official documentation.
You Need Prompt Caching Too to Control Costs
Honestly, I was confused about this myself at first. I assumed that using the Files API would also reduce token costs. It does not. The Files API reduces network transmission costs; input-token charges are incurred the same as before. When Claude processes the same file content on every request, the token count is identical.
This is where Prompt Caching comes in. Adding cache_control: {"type": "ephemeral"} to frequently repeated content blocks dramatically lowers the input-token cost for those blocks on cache hits. Check the Anthropic pricing page for the latest cache-read rates and discount percentages by model. The rates and cache discounts for claude-sonnet-4-6 may differ from those of earlier models.
Here is the pattern for using both together. As of 2026, Prompt Caching has already reached GA, so you only need to specify cache_control — no separate beta header is required (verify against the SDK release notes at time of use).
response = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=[
{
"type": "text",
"text": "You are an expert in legal document analysis.",
"cache_control": {"type": "ephemeral"},
}
],
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "file", "file_id": file_id},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": "Summarize the key clauses in three sentences."},
],
}
],
betas=["files-api-2025-04-14"],
)The Files API covers the transport layer; Prompt Caching covers the token layer. Real, production-level savings emerge where these two layers meet.
Batch Processing and Knowledge Base Patterns
A Pipeline for Processing Hundreds of PDFs
For scenarios such as research paper analysis or automated financial report summarization, you can combine the Files API with the Message Batches API. The following is a conceptual example covering upload, batch submission, and result retrieval.
import time
from pathlib import Path
import anthropic
client = anthropic.Anthropic()
# Step 1: Bulk upload PDFs
pdf_dir = Path("./reports")
file_ids = {}
for pdf_path in pdf_dir.glob("*.pdf"):
with open(pdf_path, "rb") as f:
file_obj = client.beta.files.upload(
file=(pdf_path.name, f, "application/pdf"),
)
file_ids[pdf_path.stem] = file_obj.id
# Step 2: Build batch requests from each file_id
requests = []
for doc_name, fid in file_ids.items():
requests.append({
"custom_id": doc_name,
"params": {
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": [
{"type": "document", "source": {"type": "file", "file_id": fid}},
{"type": "text", "text": "Summarize the key figures and conclusions in 200 characters or fewer."},
],
}
],
},
})
# Step 3: Submit the batch
batch = client.beta.messages.batches.create(
requests=requests,
betas=["files-api-2025-04-14"],
)
# Step 4: Wait for completion and stream results
while True:
status = client.beta.messages.batches.retrieve(batch.id)
if status.processing_status == "ended":
break
time.sleep(30)
for result in client.beta.messages.batches.results(batch.id):
if result.result.type == "succeeded":
message = result.result.message
text = next(b.text for b in message.content if b.type == "text")
print(result.custom_id, "->", text)For the Batches API pricing structure, result format, and status values, see the Message Batches documentation.
A Shared Knowledge Base Assistant
This pattern uploads common manuals or FAQ documents once and lets multiple user sessions share the same file_id. Files are only re-uploaded when the document version changes.
KNOWLEDGE_BASE = {
"user_manual": "file_abc123",
"faq": "file_def456",
"policy": "file_ghi789",
}
def answer_support_query(user_query: str, doc_key: str) -> str:
fid = KNOWLEDGE_BASE[doc_key]
response = client.beta.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "file", "file_id": fid},
"cache_control": {"type": "ephemeral"},
},
{"type": "text", "text": user_query},
],
}
],
betas=["files-api-2025-04-14"],
)
return next(b.text for b in response.content if b.type == "text")Trade-offs
Side-by-Side Comparison
| Item | Legacy Base64 Inline | Files API |
|---|---|---|
| File transmission | Every request | Once |
| Payload size | File size × number of requests | Small (file_id only) |
| Base64 CPU cost | Every request | None |
| Input token cost | Same every request | Same every request (no change) |
| Cross-session file sharing | Not possible | Possible via file_id |
| Citations | Supported | Supported |
| Office format support | Requires PDF conversion | Requires PDF conversion (same constraint) |
| Stability | GA | Beta (interface may change) |
Common Pitfalls in Practice
The assumption that token costs dropped You may be puzzled to find your bill nearly unchanged after adopting the Files API. As explained above, token charges do not change. Using Prompt Caching in parallel is essential.
Missing the beta header
Requests without betas=["files-api-2025-04-14"] will return an error. Centralizing the header configuration across environments reduces mistakes.
Exhausting storage quotas Pipelines that upload large files frequently can hit quota limits. It is advisable to run a scheduled job that periodically cleans up old files using the list API.
Multi-region latency Files are stored in the region where they were uploaded. For global services, a mismatch between the user's location and the file storage region can affect latency, so it is wise to factor region design in early.
Oversized single files Files that exceed the per-file size limit require logical splitting before upload. Large PDFs scanned at high resolution often fall into this category.
When to Adopt It First
For the Files API's benefits to translate into actual payload reduction, you need a reuse axis — "the same file referenced multiple times." A simplified decision framework looks like this:
In short, the network savings from the Files API become noticeable once the same document is referenced three or four times or more within a session. If the prompt prefix is also stable, add Prompt Caching to capture the token layer as well; for offline bulk processing, finishing with Message Batches is the natural progression.
The beta status remains a lingering risk. Before deploying to production, review the Anthropic release notes and add a thin wrapper to absorb changes to the beta header or response schema — your future self will thank you.