| 1 | --- |
| 2 | title: Python Workers |
| 3 | description: Deploy Cloudflare Python Workers by pointing main at a .py file — alchemy uploads the modules, vendors pyproject.toml dependencies with uv, and serves them locally through workerd's built-in Pyodide. |
| 4 | --- |
| 5 | |
| 6 | Cloudflare [Python Workers](https://developers.cloudflare.com/workers/languages/python/) |
| 7 | (open beta) run CPython inside the Workers runtime via Pyodide. In |
| 8 | alchemy, a Python Worker is a regular `Cloudflare.Worker` whose `main` |
| 9 | points at a `.py` file. The infrastructure is still defined in |
| 10 | TypeScript. Only the runtime code is Python. |
| 11 | |
| 12 | There is no bundling step: the entry and every sibling `.py` module |
| 13 | upload as-is, the `python_workers` compatibility flag is added |
| 14 | automatically, and dependencies are vendored from `pyproject.toml` |
| 15 | with [uv](https://docs.astral.sh/uv/). |
| 16 | |
| 17 | :::note |
| 18 | Python Workers are always **async-style** Workers: they take no inline |
| 19 | Effect implementation (the Effect runtime is a JavaScript bundle and |
| 20 | cannot run inside Pyodide). Declare bindings with the `env` prop and |
| 21 | read them from `self.env` in Python. |
| 22 | ::: |
| 23 | |
| 24 | ## Deploy a Python Worker |
| 25 | |
| 26 | Point `main` at the Python entry and declare bindings with `env`: |
| 27 | |
| 28 | ```typescript |
| 29 | // alchemy.run.ts |
| 30 | import * as Alchemy from "alchemy"; |
| 31 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 32 | import * as Effect from "effect/Effect"; |
| 33 | |
| 34 | export default Alchemy.Stack( |
| 35 | "MyPythonApp", |
| 36 | { |
| 37 | providers: Cloudflare.providers(), |
| 38 | state: Cloudflare.state(), |
| 39 | }, |
| 40 | Effect.gen(function* () { |
| 41 | const cache = yield* Cloudflare.KV.Namespace("Cache"); |
| 42 | |
| 43 | const worker = yield* Cloudflare.Worker("Worker", { |
| 44 | main: "./src/worker.py", |
| 45 | env: { |
| 46 | CACHE: cache, |
| 47 | GREETING: "Hello from Python!", |
| 48 | }, |
| 49 | }); |
| 50 | |
| 51 | return { url: worker.url.as<string>() }; |
| 52 | }), |
| 53 | ); |
| 54 | ``` |
| 55 | |
| 56 | The handler lives in the entry module as a class extending |
| 57 | `WorkerEntrypoint`. Bindings and env vars appear on `self.env` under |
| 58 | the names given in the `env` prop: |
| 59 | |
| 60 | ```python |
| 61 | # src/worker.py |
| 62 | from workers import Response, WorkerEntrypoint |
| 63 | |
| 64 | |
| 65 | class Default(WorkerEntrypoint): |
| 66 | async def fetch(self, request): |
| 67 | cached = await self.env.CACHE.get("greeting") |
| 68 | return Response(cached or self.env.GREETING) |
| 69 | ``` |
| 70 | |
| 71 | Sibling `.py` files next to the entry upload alongside it and are |
| 72 | importable by their relative path: |
| 73 | |
| 74 | ```python |
| 75 | # src/util.py |
| 76 | def slugify(value: str) -> str: |
| 77 | return value.lower().replace(" ", "-") |
| 78 | ``` |
| 79 | |
| 80 | ```python |
| 81 | # src/worker.py |
| 82 | from util import slugify |
| 83 | ``` |
| 84 | |
| 85 | ## Dependencies with pyproject.toml |
| 86 | |
| 87 | Put a `pyproject.toml` next to the entry module. On deploy, alchemy |
| 88 | vendors `[project.dependencies]` with uv and uploads them under |
| 89 | `python_modules/`, the same layout Wrangler and |
| 90 | [pywrangler](https://github.com/cloudflare/workers-py) use: |
| 91 | |
| 92 | ```toml |
| 93 | # src/pyproject.toml |
| 94 | [project] |
| 95 | name = "my-worker" |
| 96 | version = "0.1.0" |
| 97 | requires-python = ">=3.13" |
| 98 | dependencies = [ |
| 99 | "fastapi", |
| 100 | "httpx", |
| 101 | "humanize", |
| 102 | ] |
| 103 | ``` |
| 104 | |
| 105 | ```python |
| 106 | # src/worker.py |
| 107 | import humanize |
| 108 | from workers import Response, WorkerEntrypoint |
| 109 | |
| 110 | |
| 111 | class Default(WorkerEntrypoint): |
| 112 | async def fetch(self, request): |
| 113 | return Response(f"{humanize.intcomma(1234567)} requests served") |
| 114 | ``` |
| 115 | |
| 116 | Under the hood, alchemy runs the same steps `pywrangler sync` does: |
| 117 | |
| 118 | 1. `uv pip compile` resolves the dependencies against the |
| 119 | [Pyodide wheel index](https://index.pyodide.org) for the runtime's |
| 120 | Python version into a `pylock.toml` (prebuilt wheels only, since |
| 121 | Pyodide-platformed wheels can't be built locally). |
| 122 | 2. `uv venv` + `uv pip install --no-build` install the locked wheels |
| 123 | into an `emscripten-wasm32` cross-venv. |
| 124 | 3. The venv's `site-packages` becomes the uploaded `python_modules/` |
| 125 | directory. |
| 126 | |
| 127 | The managed `workers-runtime-sdk` package (the `workers` Python |
| 128 | module) is always vendored alongside your own dependencies. Results |
| 129 | are cached under `.alchemy/python/` and only re-vendored when |
| 130 | `pyproject.toml` changes. |
| 131 | |
| 132 | :::caution |
| 133 | [uv](https://docs.astral.sh/uv/getting-started/installation/) (>= 0.8.10) |
| 134 | must be installed for dependency vendoring. Workers without a |
| 135 | `pyproject.toml` deploy without uv. Only pure-Python packages, |
| 136 | packages with `emscripten-wasm32` wheels, and |
| 137 | [packages built into Pyodide](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) |
| 138 | resolve, and HTTP clients must be async (`httpx`, `aiohttp`, or JS |
| 139 | `fetch` via the FFI). |
| 140 | ::: |
| 141 | |
| 142 | ### ASGI apps (FastAPI) |
| 143 | |
| 144 | The runtime ships an `asgi` module that serves any ASGI application, |
| 145 | so FastAPI (with pydantic validation) works out of the box. Bindings |
| 146 | and env vars reach your routes through `request.scope["env"]`: |
| 147 | |
| 148 | ```python |
| 149 | # src/worker.py |
| 150 | import asgi |
| 151 | from fastapi import FastAPI, Request |
| 152 | from pydantic import BaseModel |
| 153 | from workers import WorkerEntrypoint |
| 154 | |
| 155 | app = FastAPI() |
| 156 | |
| 157 | |
| 158 | class Item(BaseModel): |
| 159 | name: str |
| 160 | quantity: int |
| 161 | |
| 162 | |
| 163 | @app.get("/") |
| 164 | async def root(): |
| 165 | return {"message": "Hello from FastAPI on Workers"} |
| 166 | |
| 167 | |
| 168 | @app.get("/env") |
| 169 | async def env(request: Request): |
| 170 | return {"deployment": request.scope["env"].DEPLOYMENT} |
| 171 | |
| 172 | |
| 173 | @app.post("/items") |
| 174 | async def create_item(item: Item): |
| 175 | return {"name": item.name, "total": item.quantity * 2} |
| 176 | |
| 177 | |
| 178 | class Default(WorkerEntrypoint): |
| 179 | async def fetch(self, request): |
| 180 | return await asgi.fetch(app, request, self.env) |
| 181 | ``` |
| 182 | |
| 183 | ```toml |
| 184 | # src/pyproject.toml |
| 185 | [project] |
| 186 | name = "my-api" |
| 187 | version = "0.1.0" |
| 188 | requires-python = ">=3.13" |
| 189 | dependencies = ["fastapi"] |
| 190 | ``` |
| 191 | |
| 192 | Compiled-extension packages with Pyodide wheels (numpy, pydantic-core, |
| 193 | and the rest of the |
| 194 | [Pyodide package set](https://pyodide.org/en/stable/usage/packages-in-pyodide.html)) |
| 195 | vendor the emscripten-wasm32 binary wheel matching the runtime's ABI. |
| 196 | There is no build step on your side. |
| 197 | |
| 198 | ### Bring your own python_modules |
| 199 | |
| 200 | If the entry's directory already contains a `python_modules/` |
| 201 | directory, produced by `pywrangler sync` or any other tool that |
| 202 | emits Wrangler's vendored layout, alchemy uploads it byte-for-byte |
| 203 | and never invokes uv. This is the escape hatch for teams that manage |
| 204 | Python dependencies outside of alchemy. |
| 205 | |
| 206 | ## Python version |
| 207 | |
| 208 | The runtime's Python version follows Cloudflare's compatibility |
| 209 | rules, and alchemy targets the matching uv interpreter and wheel |
| 210 | index automatically: |
| 211 | |
| 212 | | Python | Selected when | |
| 213 | | ------ | ------------- | |
| 214 | | 3.13 | `compatibility.date >= "2025-09-29"` (alchemy's default date) or the `python_workers_20250116` flag | |
| 215 | | 3.12 | earlier dates, or the `no_python_workers_20250116` flag | |
| 216 | |
| 217 | ```typescript |
| 218 | const worker = yield* Cloudflare.Worker("Worker", { |
| 219 | main: "./src/worker.py", |
| 220 | // pin Python 3.12 |
| 221 | compatibility: { flags: ["no_python_workers_20250116"] }, |
| 222 | }); |
| 223 | ``` |
| 224 | |
| 225 | ## Local dev |
| 226 | |
| 227 | `alchemy dev` serves Python Workers from local |
| 228 | [workerd](https://github.com/cloudflare/workerd), which embeds Pyodide. |
| 229 | The same module graph that would upload runs locally, bindings |
| 230 | included. Edits to any `.py` file under the entry's directory (or to |
| 231 | `pyproject.toml`, which re-vendors) restart the local Worker. |
| 232 | |
| 233 | ## Notes |
| 234 | |
| 235 | - Python Workers are in **open beta** on Cloudflare's side; the |
| 236 | `python_workers` compatibility flag is required and added for you. |
| 237 | - Handlers are class-based (`class Default(WorkerEntrypoint)`); the |
| 238 | legacy top-level `on_fetch` style requires an extra compatibility |
| 239 | flag and is not recommended. |
| 240 | - Cold starts are mitigated server-side: Cloudflare snapshots the |
| 241 | Worker's memory after top-level imports at deploy time. There is |
| 242 | nothing to configure. |
| 243 | - Vendored `python_modules/` files count toward the Worker size limit |
| 244 | (3 MiB free / 10 MiB paid, gzipped). |
| 245 | - Durable Objects, Workflows, and the rest of the binding surface are |
| 246 | reachable from Python via `self.env`; see Cloudflare's |
| 247 | [Python Workers docs](https://developers.cloudflare.com/workers/languages/python/) |
| 248 | for the Python-side API. |