Deploying FastAPI to Cloudflare Workers with Python
Cloudflare will run your FastAPI app in 330-plus cities for five dollars a month, and that number makes every builder pause before renewing a container plan. The catch is that "Python on Workers" does not mean CPython on a Linux box — it means Pyodide, a WebAssembly build of Python, inside a V8 isolate with no filesystem, no threads, no TCP sockets, and no pip install at runtime. Part of the Deploying APIs to Render or Vercel guide, this page resolves one decision: does your commercial API belong on Python Workers, or does it belong on a container host? The answer turns on your dependency tree and your data layer, not on latency benchmarks.
What actually runs on a Python Worker
A Workers deploy does not spawn a process. Cloudflare loads your module into a V8 isolate that already has a Pyodide interpreter compiled to WebAssembly, hands the request to your handler, and tears the isolate down when it goes idle. There is no uvicorn, no worker pool, no WEB_CONCURRENCY to tune — a decision that dominates throughput on a normal box, as Uvicorn vs Gunicorn worker configuration explains, and that simply does not exist here. Concurrency is the platform's problem: one isolate handles one request at a time, and Cloudflare spins up as many isolates as it needs across the edge network.
That model gives you three things a container never will: zero idle cost, automatic global placement, and a request path with no load balancer hop. It takes away everything that assumes an operating system. Your code cannot open a socket, fork a process, write to /tmp and expect the file to survive, or import a wheel that was not compiled for the wasm32-emscripten target. Every piece of state lives behind a binding — a KV namespace, a D1 database, an R2 bucket, a queue, or a Durable Object — injected as an object on env rather than reached over a connection string.
The ASGI bridge, in code
FastAPI is an ASGI application, and Cloudflare ships an adapter that translates a Workers Request into an ASGI scope and back. Your entry module exports on_fetch, and the adapter drives your app. If you already know how to set up FastAPI, nothing about routing, dependencies, or Pydantic models changes — only the process boundary does.
# src/entry.py — runs on Cloudflare Python Workers
import os
import json
from fastapi import FastAPI, Depends, HTTPException, Request
import asgi # provided by the Workers Python runtime
app = FastAPI(title=os.getenv("API_TITLE", "edge-api"))
# The Workers env object is not os.environ; stash it per request so
# dependencies can read bindings and secrets from one place.
_runtime: dict[str, object] = {}
def settings(request: Request) -> dict[str, object]:
env = _runtime.get("env")
return {
"env": env,
"free_quota": int(os.getenv("FREE_TIER_DAILY_QUOTA", "1000")),
"api_key": getattr(env, "API_SIGNING_KEY", None),
}
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok", "runtime": "workers-python"}
@app.get("/v1/quote/{symbol}")
async def quote(symbol: str, cfg: dict = Depends(settings)) -> dict:
env = cfg["env"]
cached = await env.QUOTES.get(f"quote:{symbol}")
if cached is not None:
return json.loads(cached)
upstream = os.getenv("UPSTREAM_QUOTE_URL")
if not upstream:
raise HTTPException(status_code=503, detail="upstream not configured")
# fetch() is the only outbound transport available in an isolate.
from js import fetch # noqa: PLC0415
response = await fetch(f"{upstream}?symbol={symbol}")
if response.status != 200:
raise HTTPException(status_code=502, detail="upstream failed")
payload = json.loads(await response.text())
await env.QUOTES.put(f"quote:{symbol}", json.dumps(payload), expirationTtl=60)
return payload
async def on_fetch(request, env):
_runtime["env"] = env
return await asgi.fetch(app, request, env)
Two details matter commercially. First, env.QUOTES is a KV binding, so your cache is global and free of connection management — the trade-off against a Redis instance is covered in Redis vs in-memory caching for FastAPI. Second, from js import fetch is your only outbound path. An async HTTP client you know well, like the one compared in httpx vs requests for async, only works here if it has been ported to route through fetch; assume the raw socket transport is unavailable until you test it.
The deploy config is small and belongs in version control:
// wrangler.jsonc
{
"name": "edge-api",
"main": "src/entry.py",
"compatibility_date": "2026-01-15",
"compatibility_flags": ["python_workers"],
"kv_namespaces": [{ "binding": "QUOTES", "id": "$KV_NAMESPACE_ID" }],
"d1_databases": [{ "binding": "DB", "database_id": "$D1_DATABASE_ID" }],
"observability": { "enabled": true }
}
Dependencies go in a requirements.txt next to it, and they are resolved at deploy time from the packages Cloudflare bundles with the runtime. There is no arbitrary wheel install and no uv sync on the edge.
The dependency wall
This is where most migrations die. Pyodide only runs pure-Python packages and C extensions that Cloudflare has compiled to WebAssembly and shipped inside the runtime. FastAPI, Starlette, Pydantic, Jinja2, and a large slice of the pure-Python ecosystem are fine. Anything that opens a socket, links against libpq, spawns a thread, or needs a native binary is not.
| Dependency | Status on Python Workers | Replacement |
|---|---|---|
| FastAPI, Pydantic v2 | Supported | none needed |
| asyncpg, psycopg3 | No TCP sockets | D1 or Hyperdrive |
| redis-py | No TCP sockets | KV binding |
| SQLAlchemy async ORM | Driver unavailable | D1 SQL, raw |
| Celery, APScheduler | No threads or process | Queues, Cron Triggers |
| boto3, requests | Blocking sockets | R2 binding, fetch |
The practical test takes ten minutes: create a throwaway Worker, put your real requirements.txt in it, and run npx wrangler deploy --dry-run. If the import graph resolves, you have a candidate. If your data layer is built on async SQLAlchemy over asyncpg, you are rewriting queries against D1's SQL API, not porting an app.
Cold starts are real, just small
A JavaScript Worker starts in roughly a millisecond. A Python Worker has to bring up a Pyodide interpreter and import your module graph, which is meaningfully slower — hundreds of milliseconds on a naive boot, and worse once FastAPI and Pydantic are imported. Cloudflare mitigates this with memory snapshots taken at deploy time: the interpreter state after your imports is captured once and restored per isolate, which cuts a full interpreter boot down to a fraction of it.
The number that matters is not the cold start in isolation but how often you pay it. Isolates stay warm while traffic flows, so a Worker serving even a few requests a minute per region rarely cold-starts. A container that scales to zero — the pattern that makes Fly.io cheap in Render vs Railway vs Fly.io — wakes far more slowly, because it boots a machine, a Python process, and an ASGI server before it can answer.
Bindings replace your Postgres
Losing a socket layer sounds catastrophic until you look at what a young commercial API actually stores. Most of it is API keys, per-customer quota counters, cached upstream responses, and an append-only usage log. KV handles the first three well — reads are single-digit milliseconds from cache and writes propagate globally within seconds. Quota counters that must be exact belong in a Durable Object, which serialises writes for one key. Usage events belong in a queue that drains to durable storage, which keeps the request path fast and is the same shape recommended in logging API usage events to Postgres.
# src/quota.py — quota + key auth using bindings, not a database connection
import os
import json
from datetime import date
FREE_DAILY = int(os.getenv("FREE_TIER_DAILY_QUOTA", "1000"))
async def authorize(env, api_key: str) -> dict:
"""Return the customer record for an API key, or raise."""
raw = await env.KEYS.get(f"key:{api_key}")
if raw is None:
raise PermissionError("unknown api key")
return json.loads(raw)
async def charge_request(env, customer: dict, units: int = 1) -> int:
"""Increment today's counter in a Durable Object and return the new total."""
key = f"{customer['id']}:{date.today().isoformat()}"
stub = env.QUOTA.get(env.QUOTA.idFromName(key))
response = await stub.fetch(
os.getenv("QUOTA_STUB_URL", "https://quota.internal/incr"),
method="POST",
body=json.dumps({"units": units}),
)
total = int(await response.text())
match customer.get("plan"):
case "free" if total > FREE_DAILY:
raise PermissionError("free tier quota exhausted")
case "metered":
await env.USAGE.send({"customer": customer["id"], "units": units})
case _:
pass
return total
The USAGE queue consumer can run as a second Worker or as a container job that writes to your real warehouse. That split is the honest architecture: the hot path stays at the edge, the analytical path stays where SQL lives. Authentication logic is unchanged — the trade-off between signed tokens and stored keys in JWT vs API keys applies identically, and a JWT check is actually the better fit because it avoids a KV read per request.
When to choose Workers, when to stay on a container
Choose Workers when your API is read-heavy, latency-sensitive, globally distributed, and thin on dependencies: an enrichment endpoint, a signed-URL issuer, a geolocation or feature-flag lookup, a webhook receiver that validates a signature and enqueues work. At 1M requests a month with 4 ms of CPU each, you sit inside the paid plan's included allowance and pay about five dollars — versus seven to twenty-five for a small always-on container plus a managed database. Run your own numbers using calculating cost per API request before you assume the edge is cheaper; it usually is, until you need Postgres.
Stay on a container when you have a real relational schema with migrations, a background job system, long-running work that exceeds the CPU budget, machine-learning dependencies with native wheels, or a team that needs the local-dev parity a Docker image gives you. Anything that looks like background jobs with Celery is a container workload, full stop.
The migration path that does not burn a weekend
Do not port the whole app. Move one endpoint, measure, then decide.
- Create the Worker with
npx wrangler init --template hello-world-pythonand copy in your smallest read-only route. - Add
fastapitorequirements.txt, setcompatibility_flagsto includepython_workers, and runnpx wrangler devlocally against real bindings. - Replace every direct database read on that route with a KV or D1 call; keep the Pydantic response models untouched so your contract does not move.
- Push secrets with
npx wrangler secret put— never commit them — and deploy to a subdomain likeedge.your-api.com. - Route a slice of traffic there with a Cloudflare rule, watch p95 and error rate through Workers observability, and widen the slice only when it holds. The safety mechanics are the same as any zero-downtime deploy.
Keep your container running the entire time. The hybrid state — edge Worker in front, container behind for writes and jobs — is a legitimate destination, not a halfway house, and it is where most profitable small APIs end up.
Builder verdict
For your first or second commercial API, the container host wins, and Cloudflare Python Workers wins the layer in front of it. Ship the product on Render or Fly where Postgres, migrations, Celery, and structured logging all work the way you already know, because shipping velocity is worth far more than saving fifteen dollars a month. Then move the read-heavy, latency-sensitive endpoints — lookups, validators, webhook receivers, cache fronts — onto a Python Worker where they cost effectively nothing and answer in the customer's own city. Betting the whole product on Pyodide today means rewriting your data layer for D1 to save a rounding error on your hosting bill, and that trade only pays off once traffic is large enough that per-request compute genuinely shows up in your margin.
FAQ
Is Cloudflare Python Workers cheaper than a container at 1M requests a month? Usually yes on compute alone. The Workers Paid plan starts at five dollars and includes an allowance that a 1M-request-per-month API with a few milliseconds of CPU per request stays inside, while a small always-on container plus managed Postgres runs fifteen to thirty dollars. The saving evaporates if you still need that Postgres for writes, so compare total architecture cost, not the compute line.
Will my existing FastAPI app deploy without changes? Only if its dependency tree is pure Python or already bundled in the runtime. Routing, dependencies, and Pydantic models carry over untouched, but any asyncpg, psycopg, redis-py, boto3, or Celery import fails because isolates have no sockets, threads, or processes. Run a dry-run deploy with your real requirements file before you plan the migration.
How risky is committing my data layer to D1 and KV? Moderate and one-directional. D1 speaks SQLite SQL, so simple schemas port back to Postgres with modest effort, but KV's eventual consistency and Durable Object patterns have no direct equivalent on a container. Keep the customer-of-record data in a portable relational store and use edge storage for caches, quotas, and keys you can rebuild.
Do cold starts hurt paying customers? Rarely. Isolates stay warm while traffic flows, and Cloudflare's deploy-time memory snapshots cut Python startup to roughly 400 ms in the worst case. Enterprise-grade latency guarantees are a different conversation, but for a metered API the cold path is a fraction of a percent of requests.
Can I run scheduled jobs and billing sync on Workers? Short ones, yes, using Cron Triggers and Queues. Anything that needs minutes of CPU, a long database transaction, or a retry framework belongs on a container worker. Keep billing reconciliation and invoice generation off the edge — those jobs want a real process and a real database.
Related
Same track:
- Deploying APIs to Render or Vercel — the parent guide covering hosting choices end to end.
- Render vs Railway vs Fly.io — the container hosts you should compare against before going all-in on the edge.
- Best Platforms to Host Python APIs for Free — where a zero-budget prototype can live before it earns revenue.
Other tracks:
- Containerizing Python APIs with Docker — the fallback runtime for everything Pyodide cannot import.
- Calculating Cost per API Request — turn edge versus container pricing into a real margin number.