Claude’s Files API lets you upload a PDF, image, or text file once and reuse it across many Messages API calls by passing its file_id instead of re-sending the bytes. This cuts the request payload for repeated document Q&A down to almost nothing, since the file is already stored on Anthropic’s servers and only the pointer travels with each request. Below is the exact upload-then-reference flow, the file types it supports, and the errors you will hit if you treat it like a one-shot upload instead of a reusable resource.
Image credit: BalticServers.com, licensed under CC BY-SA 3.0, via Wikimedia Commons.
What the Files API actually stores
The Files API is a separate storage layer from the Messages API: you POST a file once to /v1/files, get back an id shaped like file_011CNha8iCJcU1wXNR6q4V8w, and that id stays valid until it expires or you delete it. The file is not tied to a single conversation or session — it is shared across your entire workspace, which matters as much for security as for convenience (more on that below). This is different from attaching a base64-encoded PDF directly inside a messages.create call, which re-uploads the full content, byte for byte, on every single request.
Step 1: Upload the file once
The upload itself is a single multipart call. Using the Python SDK:
uploaded = client.files.upload(
file=("document.pdf", open("/path/to/document.pdf", "rb"), "application/pdf"),
)
file_id = uploaded.id
The response gives you the fields you need to track if you are managing files at any scale:
{
"id": "file_011CNha8iCJcU1wXNR6q4V8w",
"type": "file",
"filename": "document.pdf",
"mime_type": "application/pdf",
"size_bytes": 1024000,
"created_at": "2025-01-01T00:00:00Z",
"downloadable": false,
"expires_at": null
}
expires_at is null unless you asked for automatic expiry at upload time, which is the detail most teams miss and then have to debug later.
Step 2: Reference the file_id instead of re-uploading
Once you have the id, every later request sends it inside a content block instead of the file itself:
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Please summarize this document for me."},
{
"type": "document",
"source": {"type": "file", "file_id": file_id},
},
],
}
],
)
Here is why each part of that block exists, since copying it without understanding it is how teams end up with a 400 error later: the outer content array lets you mix a text instruction with one or more attached files in the same turn. The block’s type is document because PDFs and plain text go through the document pipeline, not the image pipeline — that distinction controls how Claude parses the file internally. And source.type: "file" is what tells the API to look up an id in your workspace’s file store, as opposed to source.type: "base64", which would expect the raw bytes inline. Swap in {"type": "image", "source": {"type": "file", "file_id": file_id}} for an uploaded image, or {"type": "container_upload", "file_id": file_id} for a dataset you want available to code execution.
Which block type to use, by file kind
| File kind | MIME type | Content block type |
|---|---|---|
| application/pdf | document | |
| Plain text | text/plain | document |
| Images | image/jpeg, image/png, image/gif, image/webp | image |
| Datasets for code execution | varies | container_upload |
Formats outside that table, like .docx or .xlsx, are not accepted directly — convert them to plain text first, or to PDF if you need Claude to read embedded images or layout.
Before and after: what actually changes on the wire
Before, a typical “ask three questions about the same 40-page PDF” workflow sends the full base64-encoded document inline in the messages array three separate times — once per request — because each call is stateless and the SDK has no idea you already sent that file five minutes ago. For a few-megabyte PDF, that means a few megabytes of duplicate payload on every single follow-up question, all of it billed and transmitted again.
After, you upload once, store file_id next to the document’s record in your own database, and every later question — regardless of which server or process asks it — sends only the small JSON block shown above. The file transfer cost is paid exactly once, and any part of your system that has the id can reference the same file without needing the original bytes at all.
Limits that decide whether this fits your workload
- Maximum file size: 500 MB per file.
- Total storage: 1 TB per organization.
- File-related calls (upload, list, retrieve, delete) are capped at roughly 500 requests per minute.
- Listing files is paginated, with a default
limitof 20 and a maximum of 1,000, using anext_pagecursor.
Errors you will hit, and what actually causes each one
- 400 error on download.
GET /v1/files/{file_id}/contentonly works for files your account generated through skills or code execution. Trying to download a file you uploaded yourself always returns a 400 — the Files API is not a general file-hosting endpoint, it is upload-in, reference-out. - 404 after expiry. If you set
expires_in_secondsat upload (valid range: 3,600 seconds to 7,776,000 seconds, i.e. one hour to 90 days), any content download after that timestamp returns 404, and any Messages request that still references the id fails before inference even starts. Metadata stays queryable for up to 30 days past expiry so you can at least confirm what happened. - Stale file reused across tenants. Files are shared across the whole workspace, not scoped to a user or session. If your product lets end users pass a
file_iddirectly, one user can reference another user’s uploaded file simply by guessing or receiving the id — keep the user-to-file mapping on your own server and never accept a rawfile_idfrom client input. - Trying to rename or edit a file in place. Files cannot be modified after upload. If the content changes, upload a new file and delete the old
file_id; there is no update endpoint.
Cleaning up: delete is permanent
client.files.delete(file_id) calls DELETE /v1/files/{file_id}, and there is no recovery after that — the docs are explicit that deletion cannot be undone. If you only need a file around temporarily, setting expires_in_seconds at upload time is safer than relying on a cleanup job you might forget to run, since the expiry is enforced automatically and cannot be changed later. Expiry is also not an instant-delete guarantee: Anthropic may retain content briefly afterward for safety review, so if you need a file gone immediately for compliance reasons, call delete explicitly instead of waiting for expires_at to pass.
Where this fits with the rest of the Claude API
The Files API solves a different problem than prompt caching: caching avoids re-processing the same tokens within a cache window, while the Files API avoids re-transmitting the same bytes at all, indefinitely, across sessions. If you are already scheduling repeated Claude API calls against the same source document — for example from a cron-triggered summarization job — swapping inline base64 attachments for a stored file_id is usually the first optimization worth making, before touching caching or model choice. It is also worth checking how your chosen model reports token usage around file content; Claude, GPT, and Gemini differ in how they report a truncated response when a large attached document pushes a request over its output limit, and the model deprecation timeline is a separate thing to track if you pin a specific Claude version id in production code that references stored files.
Migrating off the old beta header
The Files API started in beta behind an anthropic-beta: files-api-2025-04-14 header and is now out of beta, so the header is no longer required. Requests that still send it keep working, but you get the older response shape — has_more, first_id, and last_id for pagination instead of page and next_page, and before_id/after_id cursors instead of the current ones. Migrating just means dropping the header and switching to client.files instead of client.beta.files in current SDK versions — there is no behavior change to test for beyond the pagination field names.
Checking what you already have stored
Before uploading a new copy of a document, it is worth checking whether your own database already holds a live file_id for it. If you only kept the id and want to confirm the file is still there and not expired, client.files.retrieve_metadata(file_id) calls GET /v1/files/{file_id} and returns the same shape as the upload response, including the current expires_at. For a bulk check — say, a nightly job that audits which stored files are about to expire — client.files.list() accepts up to 100 ids at once through an ids[] parameter, which is cheaper than retrieving each file’s metadata one at a time:
page = client.files.list(limit=100)
for f in page.data:
print(f.id, f.filename, f.expires_at)
Results come back newest first, so a scheduled audit job can stop paging as soon as it reaches files it already checked the previous run.
FAQ
Does deleting a file break a conversation that already referenced it? It breaks any future request that references that file_id, not a conversation’s past turns — Claude does not re-fetch a file for messages that already received a response. If your application replays conversation history to continue a thread, and that history includes a document block pointing at a now-deleted id, the replay will fail at the point where the file is referenced, so persisting a local copy alongside the id is safer for any content you need to replay later.
Can two different workspaces share the same uploaded file? No — storage is scoped to one workspace, which is also why Anthropic’s own guidance treats the workspace as the isolation boundary for multi-tenant products: give each tenant its own workspace rather than trying to separate their files inside a single shared one.
Source: Anthropic’s Files API documentation.



