A few days ago, an internal tool I had built on top of my own app business — the one that has accumulated over 50 million downloads since 2014 — started failing the moment a user uploaded a roughly 20 MB PDF. The same pipeline had been happily summarising smaller PDFs for weeks, so the sudden RESOURCE_EXHAUSTED was both confusing and quietly demoralising for the half hour I spent staring at logs.
The short version: a single Gemini API request has an overall payload ceiling of about 20 MB, and inlineData makes that ceiling tighter than it looks because Base64 encoding inflates the binary by roughly 4/3. For anything close to production, the real question is where in your pipeline you decide to switch over to the Files API. This article walks through that decision line and the actual migration steps.
Why inlineData Fails for Larger Files
Gemini API requests have an overall size cap of around 20 MB including metadata. When you embed a file directly via inlineData, the payload is Base64-encoded internally, which expands the original binary by about 33 percent.
Concretely:
- 12 MB original → ~16 MB after Base64 → request fits, just barely
- 16 MB original → ~21 MB after Base64 → exceeds the cap →
RESOURCE_EXHAUSTED - 30 MB video → ~40 MB after Base64 → fails immediately
The error you actually see looks like this:
google.api_core.exceptions.ResourceExhausted: 429 RESOURCE_EXHAUSTED.
{'error': {'code': 429, 'message': 'Resource has been exhausted (e.g. check quota).', 'status': 'RESOURCE_EXHAUSTED'}}
The wording strongly suggests a quota issue, but payload-size violations come back with the very same status. That overlap is what makes this bug expensive to diagnose the first time you hit it. If your daily quota looks healthy in the Cloud console and yet a single request fails immediately, payload size is almost always the actual cause.
A Simple Rule for When to Switch
Google's documentation publishes the size limits but stops short of telling you where to draw the migration line. After more than ten years of shipping indie apps, the rule I now apply is straightforward:
- If the total payload is likely to exceed 15 MB, start with the Files API
- If the file will be passed to the API more than once, use the Files API
- For one-off scripts and quick experiments,
inlineDatais fine - For any production system that accepts user uploads, default to Files API
The second point is easy to underestimate. With Files API you can reuse the same upload URI for 48 hours, which dramatically cuts both bandwidth and latency for any pipeline that runs the file through multiple prompts or models. In a recent audit of one of my pipelines, switching repeated calls from inlineData to a cached Files URI cut p95 latency by roughly 40 percent simply because the binary stopped being re-uploaded for every prompt iteration.
Migrating to Files API (Python)
Here is the "before" version using inlineData:
# inlineData approach — fine for small files
from google import genai
from google.genai import types
import pathlib
client = genai.Client(api_key="YOUR_API_KEY")
pdf_bytes = pathlib.Path("report.pdf").read_bytes()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
"Summarise the three most important points of this PDF in English.",
],
)
print(response.text)This works fine until the PDF approaches 20 MB. Then the RESOURCE_EXHAUSTED errors start appearing intermittently. Here is the Files API equivalent:
# Files API approach — for >15 MB or reusable assets
from google import genai
import pathlib, time
client = genai.Client(api_key="YOUR_API_KEY")
# 1. Upload (processed in the background)
uploaded = client.files.upload(file="report.pdf")
# 2. Wait for processing to finish (videos and large PDFs may take a while)
while uploaded.state.name == "PROCESSING":
time.sleep(2)
uploaded = client.files.get(name=uploaded.name)
if uploaded.state.name == "FAILED":
raise RuntimeError(f"Upload failed: {uploaded.state}")
# 3. Pass the URI directly into contents
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
uploaded,
"Summarise the three most important points of this PDF in English.",
],
)
print(response.text)
# Expected output (truncated):
# 1. Market trends in 2026 around ...
# 2. Shifts in market share among the major players ...
# 3. Three growth axes anticipated next year ...Two things are worth noting. First, videos and longer PDFs frequently come back with state == PROCESSING, so you must include the polling loop. Second, you can hand the upload result object straight into contents — no manual URI string construction required, unlike older SDK examples.
A common mistake is to skip the polling step and immediately pass the freshly uploaded object to generate_content. The call will not raise — it will simply return a low-quality summary because the model received an asset that was not yet ready. This silent degradation is much harder to spot than a clean error, which is why the polling loop deserves to live in a small helper function from day one.
TypeScript / Node.js Equivalent
The pattern is essentially identical with the JavaScript SDK. If your stack is Node.js or Cloudflare Workers, the migration looks like this:
// Files API in @google/genai (Node.js / Bun)
import { GoogleGenAI } from "@google/genai";
import * as fs from "node:fs";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
async function summarise(pdfPath: string) {
// Upload (returns immediately; processing continues server-side)
let file = await ai.files.upload({
file: pdfPath,
config: { mimeType: "application/pdf" },
});
// Poll until ready
while (file.state === "PROCESSING") {
await new Promise((r) => setTimeout(r, 2000));
file = await ai.files.get({ name: file.name! });
}
if (file.state === "FAILED") throw new Error("upload failed");
const result = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [
{ fileData: { fileUri: file.uri!, mimeType: file.mimeType! } },
{ text: "Summarise the three most important points in English." },
],
});
return result.text;
}One subtle difference from the Python SDK is that the JavaScript SDK does not currently accept the file object directly inside contents; you have to construct a fileData part manually with fileUri and mimeType. Forgetting the mimeType field is the single most common source of silent failures here, so keep an eye on it during code review.
Side Effects to Watch For
Three pitfalls show up consistently after migration. I have run into all of them in production.
The first is the 48-hour expiry. Files uploaded via Files API are deleted automatically after 48 hours. Long batch jobs that pause between phases sometimes hit File URI not found halfway through. The safe pattern is to re-upload at the start of each batch session. The behaviour is covered in detail in Gemini API: Resolving "File URI Not Found" Errors After Restart.
The second is that the same filename produces different URIs each time you upload. upload() always creates a fresh resource, so cache the URIs in a dictionary keyed by content hash if you plan to re-process the same file repeatedly. SHA-256 of the bytes works well as a key and gives you cheap deduplication for free.
The third is upload failures themselves. On flaky networks the upload call can time out, so build in retry logic — or, for files larger than around 100 MB, switch to a Cloud Storage based pipeline. That route is documented in Gemini API: A 100 MB File Processing Pipeline via Cloud Storage.
How I Decide for My Own Projects
I have been building apps independently since 2014, and at the AdMob revenue peak that work brought in over 1.5 million yen per month. The lesson that experience hammered home is simple: cost and latency are decided in the first design pass, not later.
For a wallpaper app where users post images that an AI then categorises, files are typically under 1 MB and inlineData is the right choice. Conversely, when I analyse high-resolution files from my own art practice — RAW images that easily reach tens of megabytes — I upload through the Files API from the start so I can run multiple models against the same asset without paying the bandwidth cost twice.
A heuristic I use in code reviews of my own projects: any function that builds a Gemini request and accepts a user-supplied file should always go through the Files API. The marginal complexity of one extra upload step is much smaller than the operational cost of a payload-size outage on a Saturday evening when the pipeline starts trending on social media for unrelated reasons.
When you are unsure, ask yourself: "will this file be used in only this single request?" If yes, inlineData. If no or unclear, Files API. That single question prevents almost every quiet RESOURCE_EXHAUSTED outage.
For a deeper operational treatment of the Files API specifically, see Gemini API: Files API Troubleshooting Complete Guide.
One concrete next step: if you currently use inlineData anywhere, add a single log line that prints the payload size before each request. Once you can see how many of your daily calls cross the 15 MB threshold, the decision to migrate becomes obvious instead of theoretical. Hope this saves someone the same half hour of confused log staring.