Skip to content

Python Workers

Cloudflare Python Workers (open beta) run CPython inside the Workers runtime via Pyodide. In alchemy, a Python Worker is a regular Cloudflare.Worker whose main points at a .py file — the infrastructure is still defined in TypeScript, only the runtime code is Python.

There is no bundling step: the entry and every sibling .py module upload as-is, the python_workers compatibility flag is added automatically, and dependencies are vendored from pyproject.toml with uv.

Point main at the Python entry and declare bindings with env:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyPythonApp",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
const cache = yield* Cloudflare.KV.Namespace("Cache");
const worker = yield* Cloudflare.Worker("Worker", {
main: "./src/worker.py",
env: {
CACHE: cache,
GREETING: "Hello from Python!",
},
});
return { url: worker.url.as<string>() };
}),
);

The handler lives in the entry module as a class extending WorkerEntrypoint. Bindings and env vars appear on self.env under the names given in the env prop:

src/worker.py
from workers import Response, WorkerEntrypoint
class Default(WorkerEntrypoint):
async def fetch(self, request):
cached = await self.env.CACHE.get("greeting")
return Response(cached or self.env.GREETING)

Sibling .py files next to the entry upload alongside it and are importable by their relative path:

src/util.py
def slugify(value: str) -> str:
return value.lower().replace(" ", "-")
src/worker.py
from util import slugify

Put a pyproject.toml next to the entry module. On deploy, alchemy vendors [project.dependencies] with uv and uploads them under python_modules/ — the same layout Wrangler and pywrangler use:

src/pyproject.toml
[project]
name = "my-worker"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"fastapi",
"httpx",
"humanize",
]
src/worker.py
import humanize
from workers import Response, WorkerEntrypoint
class Default(WorkerEntrypoint):
async def fetch(self, request):
return Response(f"{humanize.intcomma(1234567)} requests served")

Under the hood, alchemy runs the same steps pywrangler sync does:

  1. uv pip compile resolves the dependencies against the Pyodide wheel index for the runtime’s Python version into a pylock.toml (prebuilt wheels only — Pyodide-platformed wheels can’t be built locally).
  2. uv venv + uv pip install --no-build install the locked wheels into an emscripten-wasm32 cross-venv.
  3. The venv’s site-packages becomes the uploaded python_modules/ directory.

The managed workers-runtime-sdk package (the workers Python module) is always vendored alongside your own dependencies. Results are cached under .alchemy/python/ and only re-vendored when pyproject.toml changes.

The runtime ships an asgi module that serves any ASGI application — so FastAPI (with pydantic validation) works out of the box. Bindings and env vars reach your routes through request.scope["env"]:

src/worker.py
import asgi
from fastapi import FastAPI, Request
from pydantic import BaseModel
from workers import WorkerEntrypoint
app = FastAPI()
class Item(BaseModel):
name: str
quantity: int
@app.get("/")
async def root():
return {"message": "Hello from FastAPI on Workers"}
@app.get("/env")
async def env(request: Request):
return {"deployment": request.scope["env"].DEPLOYMENT}
@app.post("/items")
async def create_item(item: Item):
return {"name": item.name, "total": item.quantity * 2}
class Default(WorkerEntrypoint):
async def fetch(self, request):
return await asgi.fetch(app, request, self.env)
src/pyproject.toml
[project]
name = "my-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["fastapi"]

Compiled-extension packages with Pyodide wheels (numpy, pydantic-core, and the rest of the Pyodide package set) vendor the emscripten-wasm32 binary wheel matching the runtime’s ABI — no build step on your side.

If the entry’s directory already contains a python_modules/ directory — produced by pywrangler sync or any other tool that emits Wrangler’s vendored layout — alchemy uploads it byte-for-byte and never invokes uv. This is the escape hatch for teams that manage Python dependencies outside of alchemy.

The runtime’s Python version follows Cloudflare’s compatibility rules, and alchemy targets the matching uv interpreter and wheel index automatically:

Python Selected when
3.13 compatibility.date >= "2025-09-29" (alchemy’s default date) or the python_workers_20250116 flag
3.12 earlier dates, or the no_python_workers_20250116 flag
const worker = yield* Cloudflare.Worker("Worker", {
main: "./src/worker.py",
// pin Python 3.12
compatibility: { flags: ["no_python_workers_20250116"] },
});

alchemy dev serves Python Workers from local workerd, which embeds Pyodide — the same module graph that would upload runs locally, bindings included. Edits to any .py file under the entry’s directory (or to pyproject.toml, which re-vendors) restart the local Worker.

  • Python Workers are in open beta on Cloudflare’s side; the python_workers compatibility flag is required and added for you.
  • Handlers are class-based (class Default(WorkerEntrypoint)); the legacy top-level on_fetch style requires an extra compatibility flag and is not recommended.
  • Cold starts are mitigated server-side: Cloudflare snapshots the Worker’s memory after top-level imports at deploy time — there is nothing to configure.
  • Vendored python_modules/ files count toward the Worker size limit (3 MiB free / 10 MiB paid, gzipped).
  • Durable Objects, Workflows, and the rest of the binding surface are reachable from Python via self.env; see Cloudflare’s Python Workers docs for the Python-side API.