
Porting a Real Python Backend to Cloudflare Workers: What Pyodide/WASM Actually Breaks
Introduction
Cloudflare's Python Workers are out of experimental and into general availability, and most coverage reads it as Python taking its place beside JavaScript and Rust at the edge (konsulteer.com, 2026-09-22; Technobezz, 2026-09-21). The engineering detail underneath the headline is sharper: this is Pyodide compiled to WebAssembly, not CPython on Linux, and that one sentence sorts the services that port from the ones that never will. Syntax is not the problem — async def, dataclasses, and type hints all survive. The porting cost sits in dependencies and runtime contracts: C extensions with no WASM build, a process model with no processes, and a JavaScript boundary that bills you per call. What follows is a hands-on account of moving a real Python backend across that boundary — what breaks, why, how to triage it before writing Worker code, and which workloads still make sense at the edge.
What GA Actually Means, and What the Summaries Do Not Say
Confirmed from the seed material: Cloudflare announced GA for Python Workers, the framing targets AI inference glue code and enterprise applications, and Python now sits alongside the existing JavaScript and Rust toolchains. That is the whole of what the secondary coverage establishes.
Inferred, and not stated in those news items: plan-level limits for Python Workers, whether package or bundle size ceilings differ from JS Workers, and whether Workers deployed on the experimental runtime must be re-deployed or are migrated. The seed does not answer any of it, and neither do the two news summaries.
Both news items are secondary coverage of an announcement. They are not technical authorities. Before you plan a port, read Cloudflare's Python Workers documentation and limits page directly, and check the Pyodide package index for the exact runtime version Workers pins. Treating a news summary as a compatibility list is how a port fails in week two.
The Pyodide/WASM Architecture That Decides What Ports
CPython is compiled to WebAssembly through Pyodide and executed inside a Workers isolate. There is no POSIX process, no glibc, no musl, and no Linux syscall surface. os.fork does not exist, subprocess has nothing to spawn, and ctypes cannot dlopen a .so built for x86-64 or arm64.
Everything downstream follows from that. Wheels must be built for WASM, not for a CPU. The filesystem is a memory-backed shim, so a "cache directory" is a variable with extra steps. Concurrency comes from the isolate's event loop and the bindings, not from OS threads.
The triage rule to carry through the rest of this post:
- Pure Python (no C in the dependency tree) — ports, usually unchanged.
- Native code (CPython C extensions, Rust/C++ wheels) — does not port unless a Pyodide build exists for your version.
- Everything else — a judgment call about what to relocate behind an HTTP call instead of deleting.
Breakage Class 1 — C Extensions and Native Wheels
Why manylinux Wheels Cannot Load
Wheels on PyPI tagged manylinux are compiled against glibc for real CPU architectures. Pyodide needs a separately compiled build targeting wasm32-unknown-emscripten. Installing the PyPI wheel does not error at install time — resolution succeeds, and the failure surfaces at import, which means a naive port can look healthy until the first request touches that module. The reported pattern is a ModuleNotFoundError pointing at the compiled submodule (the _psycopg or _cffi_backend half of a package), not a build error. I did not reproduce that here; treat it as the documented shape of the failure rather than a transcript.
Data-Stack Casualties: pandas, lxml, cryptography, and Friends
Expect trouble from anything with a C core or a Cython-generated extension: pandas, lxml, orjson, cryptography, Pillow, and most database drivers. Some of these have Pyodide builds and some do not. "A Pyodide build exists" is also not the same as "it is in the Workers runtime at the version I need" — the runtime pins a specific Pyodide release, and a package's Pyodide build can lag its PyPI release by months.
The Database Driver Problem
psycopg2, asyncpg, and mysqlclient are native and will not load. The practical escape hatch is a pure-Python protocol implementation — pg8000 and pymysql speak the wire protocol from Python — running over Workers TCP sockets.
Untested here, and I am flagging it as such: connecting a deployed Worker to Postgres through a socket binding. What would confirm it: a deployed Worker with the socket binding enabled that completes a handshake, runs a parameterized SELECT 1, and reports handshake latency and per-query overhead separately. Until someone publishes those numbers, treat pure-Python drivers at the edge as plausible, not proven.
Dependency Triage as a Repeatable Step
Do this before writing Worker code, not after the first failed deploy.
python -m venv .triage && . .triage/bin/activate
pip install pipdeptree
pipdeptree --warn silence --json > tree.json
Then classify. The Pyodide distribution ships a pyodide-lock.json in its wheel directory listing every package with a WASM build; point your triage script at the lock file for the version your runtime pins, and mark each node in tree.json as pure-python, pyodide-built, or native-only. Decide keep / relocate / delete for every node in the last bucket. A single native leaf can poison the whole branch.
Breakage Class 2 — Runtime Behavior Differences
No Processes, No Threads, No Subprocess
Anything built on multiprocessing, a worker pool, or shelling out to ffmpeg or ImageMagick is structurally out. This is not a configuration problem to solve — there is no process to create. Media pipelines and CPU fan-out move to a container, and the Worker calls them.
Ephemeral, Effectively Read-Only Storage
A cache written to disk does not survive the isolate. State belongs in KV, R2, D1, Durable Objects, or an external store. That is a design constraint, not a footnote: anything that used a warm on-disk cache to avoid a cold computation now pays that computation on isolate startup — the cold-start case, precisely when you can least afford it.
Sockets, Pooling, and Connection Assumptions
Long-lived pools sized per process assume the process outlives the request. When the isolate is the process, database pools mostly become per-request connects. The question shifts from "how big is the pool" to "is a per-request handshake acceptable, or do I need a pooled proxy in front of the database?" For most read-heavy paths, the proxy wins.
Time, Memory, and CPU as Design Inputs
Workers enforces a CPU-time budget separate from wall-clock time, plus a memory ceiling. I am deliberately not quoting numbers: they vary by plan and change, and the current values belong to Cloudflare's limits page, not to a blog post. Treat them as constraints that push work off the request path — queues, Durable Objects, scheduled tasks — rather than as tuning knobs to shave.
Breakage Class 3 — The JavaScript Boundary Tax
Bindings Arrive as Proxied JS Objects
KV, R2, D1, and AI bindings are JavaScript APIs surfaced into Python as proxy objects. Every call crosses a marshalling boundary. Reading a string is cheap; passing a large object graph in and out is not.
Serialization Cost Shows Up Per Call, Not Per Request
A loop that reads 200 KV keys one at a time makes 200 boundary crossings and 200 round trips. Note that the KV binding has no batched get for a list of keys — so the fix is structural: write the aggregate once under a single key and read it once, or move the bulk read to R2 or D1 where you can express the work as one query. Batch, then measure; do not assume the boundary is free because Python looks synchronous.
Async Semantics and Event-Loop Ownership
The Python side does not own the event loop. Awaiting a binding returns control to the isolate; a call you forget to await hands you a proxy and defers the work in a way that is easy to miss. A minimal entrypoint, sized as an illustration rather than a benchmark:
from workers import WorkerEntrypoint, Response, fetch
class Default(WorkerEntrypoint):
async def fetch(self, request):
tenant = request.headers.get("x-tenant") or "default"
key = "config:" + tenant
# One boundary crossing on the hot path.
cached = await self.env.CONFIG_KV.get(key)
if cached is None:
upstream = await fetch("https://config.internal/tenants", method="POST")
cached = await upstream.text()
await self.env.CONFIG_KV.put(key, cached, expiration_ttl=60)
return Response(cached, headers={"content-type": "application/json"})The import surface here has shifted across releases (expiration_ttl naming in particular). Check the current docs rather than copying this verbatim.
A Worked Port: FastAPI Service Reduced to an Edge-Safe Core
Picture a FastAPI service doing tenant auth, request validation, a Postgres-backed read, and a nightly aggregation job. Here is the honest classification.
| Package / component | Class | Decision |
|---|---|---|
pydantic (v2 core) | native core, Pyodide build exists for pinned versions | keep, verify version |
fastapi routing + validation | pure Python | keep the validation, drop the ASGI server |
httpx | pure Python over fetch | replace with fetch |
psycopg2 | native only | relocate behind HTTP |
celery / in-process scheduler | process assumption | delete, replace with Cron Triggers |
| On-disk response cache | filesystem assumption | replace with KV |
What Survived
Pure-Python request validation, routing logic, token parsing, and outbound HTTP calls via fetch. This is the part teams underestimate: the glue code ports quickly, and the FastAPI-shaped thinking ports too — a Worker handler is another request/response function.
What Moved Behind an HTTP Call
The ORM layer, background jobs, and anything touching the native data stack stay in a container. The Worker becomes a thin, cache-aware front door that validates a token, checks KV, and either serves a cached payload or calls the container.
What Was Deleted
Local disk caches, in-process schedulers, and the assumption that the process is warm. Deleting these is the actual port. The Python that remains is smaller than what you started with.
Observed Results
I did not deploy a ported Worker for this post, so I am not going to print latency numbers that nobody measured. What is often assumed, and what would actually establish it:
| Measurement | How to get it | Value here |
|---|---|---|
| Bundle size after vendoring | npx wrangler deploy --dry-run --outdir dist then du -sh dist | not measured |
| Cold start vs equivalent JS Worker | load generator after an idle period; compare first-request to warm p50 | not measured |
| p95 read path | sustained load against a cache-hit route | not measured |
If you cannot produce these three numbers for your own service before a port, you do not yet know whether the port pays for itself.
Where Python Workers Genuinely Win
Short, stateless request handlers. AI inference glue sitting in front of a model binding. Webhook normalization — parse, validate, enqueue. And the case nobody puts in a press release: if the team is fluent in Python, the win is one language across the stack and no second deployment pipeline for a thin edge tier. That is a real operational saving, and it is the reason to port a request path even when the runtime is objectively slower than Rust.
Where Python Workers Are Still the Wrong Tool
Long-running compute, native data libraries, stateful connection pools, ffmpeg-style media pipelines, and anything that needs a writable filesystem. GA does not mean every Python service should move to the edge. GA means the runtime is supported and you can now argue about it on cost and constraints instead of on stability — which is a much better argument to have, and a stricter one.
Pre-Port Checklist
- Resolve the dependency tree and classify every package as pure-Python, Pyodide-built, or native-only.
- Confirm each Pyodide build exists at the runtime's pinned version, not just somewhere upstream.
- Replace native drivers with pure-Python equivalents and test them over the socket binding.
- Delete disk and process assumptions before they delete you in production.
- Measure bundle size, cold start, and p95 before committing to the port.
- Keep a container fallback for the parts that fail — the front-door pattern needs it anyway.
Further Reading
- Cloudflare Workers Python documentation — runtime model and supported tooling.
- Cloudflare Workers limits — CPU time, memory, and bundle ceilings; read this instead of quoting numbers from coverage.
- Pyodide packages built for WebAssembly — the list that decides whether your dependency tree survives.
- workers-py / pywrangler tooling — the Python-side CLI that wraps deployment.
- Secondary coverage of the GA announcement: konsulteer.com (2026-09-22) and Technobezz (2026-09-21). Use these for the announcement, not for limits or compatibility.
Conclusion
This is not a language migration — the Python syntax mostly survives untouched. It is a dependency and runtime-contract migration, and the list of things that break is short, predictable, and discoverable before you write a single Worker line: native wheels, process assumptions, disk assumptions, and the per-call cost of crossing into JavaScript. Port the request path, leave the compute in a container, and decide with measurements rather than with the announcement.


