Skip to content

The Gemini File API: Uploading Large Files Instead of Inlining Them

9 min read · updated August 11, 2026

Gemini accepts media two ways: base64 inside the request, or a URI pointing at something you uploaded first. Above a documented total request size the first option stops being available, and the File API is not optional.

When you must use it

Google documents a 20 MB ceiling on the total size of a generateContent request. That is the whole request body — the prompt, every inline part and the base64 expansion overhead, not the size of your file on disk. Base64 inflates binary by roughly a third, so a 15 MB PDF is already about 20 MB of request.

The rule that follows: inline small images and short audio clips with inlineData; upload anything else. In practice video is always an upload, PDFs of any real length are uploads, and a request carrying several images crosses the line faster than you expect.

There is a second reason to upload even when you do not have to. A file uploaded once can be referenced by many requests without re-sending the bytes, which saves upload bandwidth and time on every call after the first. It does not save tokens — the file is tokenized on every request that references it, unless you also put it behind an explicit cache.

The workaround people try first is to split a large file across several inlineData parts, on the theory that the limit is per-part. It is not. The ceiling is on the request, so five 6 MB chunks are a 30 MB request and fail exactly as one 30 MB part would. Splitting a video into separate requests fails differently and worse: the model sees each fragment without the others and cannot answer anything that spans them.

Uploading a file

The REST path uses a resumable upload protocol with a start request that returns an upload URL, then a second request that sends the bytes. It is documented in Google’s files guide.

  1. Start the upload and capture the URL from the x-goog-upload-url response header. The metadata you send here is just a display name:
    MIME_TYPE="video/mp4"
    FILE="lecture.mp4"
    NUM_BYTES=$(wc -c < "$FILE")
    
    curl -s -D headers.tmp \
      "https://generativelanguage.googleapis.com/upload/v1beta/files" \
      -H "x-goog-api-key: $GEMINI_API_KEY" \
      -H "X-Goog-Upload-Protocol: resumable" \
      -H "X-Goog-Upload-Command: start" \
      -H "X-Goog-Upload-Header-Content-Length: $NUM_BYTES" \
      -H "X-Goog-Upload-Header-Content-Type: $MIME_TYPE" \
      -H "Content-Type: application/json" \
      -d "{'file': {'display_name': '$FILE'}}"
    
    UPLOAD_URL=$(grep -i "x-goog-upload-url: " headers.tmp | cut -d" " -f2 | tr -d "\r")
  2. Send the bytes to that URL with the upload, finalize command. The response is the file resource:
    curl "$UPLOAD_URL" \
      -H "Content-Length: $NUM_BYTES" \
      -H "X-Goog-Upload-Offset: 0" \
      -H "X-Goog-Upload-Command: upload, finalize" \
      --data-binary "@$FILE" > file_info.json
  3. Read the URI from the response. This is what you will reference:
    {
      "file": {
        "name": "files/k3n8p2xq1abc",
        "displayName": "lecture.mp4",
        "mimeType": "video/mp4",
        "sizeBytes": "48211974",
        "createTime": "2026-08-11T09:14:03.117Z",
        "expirationTime": "2026-08-13T09:14:03.117Z",
        "uri": "https://generativelanguage.googleapis.com/v1beta/files/k3n8p2xq1abc",
        "state": "PROCESSING"
      }
    }

In the Google Gen AI SDK the whole thing is one call, and this is the form to prefer in application code:

from google import genai

client = genai.Client()
myfile = client.files.upload(file="lecture.mp4")
print(myfile.uri, myfile.state)

Waiting for ACTIVE

This is where first attempts fail. The upload returns before the file is usable. state is one of STATE_UNSPECIFIED, PROCESSING, ACTIVE or FAILED, and a generation request that references a file still in PROCESSING is rejected with a 400 rather than queued.

Video is the common case, because frame extraction takes real time — seconds for a short clip, longer for an hour of footage. Poll files.get until the state settles:

import time

while myfile.state.name == "PROCESSING":
    time.sleep(2)
    myfile = client.files.get(name=myfile.name)

if myfile.state.name == "FAILED":
    raise RuntimeError(f"upload failed: {myfile.error}")
Poll with a bounded backoff and a ceiling. A file that has been PROCESSING for many minutes is more likely to be heading for FAILED than to be nearly done, and an unbounded loop here is a hang in production.

Referencing it in a request

Use a fileData part with the URI and the MIME type. The file part and the text part go in the same parts array; order matters for how the model reads them, and putting the media first with the instruction after is the convention Google’s examples use:

curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [{"role": "user", "parts": [
      {"fileData": {"mimeType": "video/mp4",
                    "fileUri": "https://generativelanguage.googleapis.com/v1beta/files/k3n8p2xq1abc"}},
      {"text": "Summarise this lecture in five bullet points, with timestamps."}
    ]}]
  }'

Before you send it, count what it will cost. Video and audio tokenize at a fixed rate per second, so a long file is a large prompt — countTokens accepts the same contents array and will tell you the number before you commit to it. An hour of video is not a small prompt.

The four errors this produces

  • The file is still processing. A 400 naming the file, raised because you referenced it before state reached ACTIVE. This is the one that passes in development on a ten-second clip and fails in production on a two-hour recording, because the processing time scales with the media and your test file was fast enough to hide the race.
  • The file has expired or been deleted. A permission or not-found error on a URI that worked earlier. The nasty version of this is a long-running chat session: the file was valid when the conversation started and the retention window elapsed while it was open, so a request that worked twenty times fails on the twenty-first with no change on your side. Catch it specifically and re-upload rather than treating it as a transport failure.
  • The request is still too large. Uploading the video does not help if you also inlined six images alongside it. The 20 MB ceiling is on the whole body, and fileData parts are tiny — it is the inlineData parts that consume it.
  • The URI belongs to another project. Files are scoped to the API key that uploaded them. A URI copied from a colleague’s session, or from a different environment’s key, resolves for them and not for you. The error is a permission failure, not a not-found, which is the clue.

Only the first of those is a race you can eliminate with polling. The other three are ordinary conditions in a long-lived system, and the durable fix is to treat the URI as a cache entry rather than an identifier: store the original bytes somewhere you control, keep the Gemini URI beside them with its expirationTime, and re-upload transparently when it is stale or rejected.

Expiry, quotas and cleanup

  • Files expire after 48 hours and are deleted automatically. The exact instant is in expirationTime on the resource. You cannot extend it; re-upload instead. Do not build a permanent document library on the File API — it is a staging area.
  • Size limits. Google documents a maximum of 2 GB per file and 20 GB of storage per project. Both are worth checking against your workload before you design around them.
  • Files are scoped to the API key’s project and are not publicly readable. The URI is not a public link, and a request from a different project will not resolve it.
  • Delete when finished with client.files.delete(name=myfile.name), and list what you have with client.files.list(). Storage is free, but a long-running job that uploads and never deletes will meet the project quota eventually.
The 20 MB request ceiling, the 48-hour retention, the 2 GB per-file cap and the 20 GB project quota are the documented figures at the time of writing. They are the kind of number that gets raised; check the files guide if you are near any of them.