Skip to content

How to deploy a uv project to Cloudflare Workers

Cloudflare Workers puts a Python API on Cloudflare’s global network without requiring you to provision servers, containers, or regional deployments. The platform handles routing and scaling, measures requests and CPU time rather than wall-clock duration, and connects Python code to services such as D1, R2, KV, and Durable Objects through bindings.

Python runs inside Pyodide, a WebAssembly build of CPython. At deployment, Cloudflare imports the app, snapshots its initialized memory, and distributes that snapshot with the code to reduce cold-start work. The official CLI, pywrangler, fits this model into a uv project by resolving pyproject.toml, writing a pylock.toml lockfileA file that records the exact version of every installed package, so everyone working on the project gets identical installs. , and packaging compatible wheels for Wrangler.

This guide deploys an existing uv-managed FastAPI app. Don’t have one? Set up a FastAPI project with uv builds one. You need Node.js (pywrangler runs Wrangler through npx) and a Cloudflare account.

Add the Workers tooling to the project

Keep the app’s runtime dependencies under [project.dependencies] and add Cloudflare’s tooling to the dev group. The --dev flag marks workers-py as a development dependencyA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag. , keeping it out of production installs:

uv add fastapi
uv add --dev workers-py workers-runtime-sdk

workers-py provides the pywrangler command. workers-runtime-sdk provides the workers module for local imports and type hints; the Workers runtime ships its own copy.

pywrangler resolves only [project.dependencies]. Pure-Python packages can use universal wheels, but packages with C or Rust extensions need a PyEmscripten wheel or a build in the Pyodide index. If the project came from the FastAPI tutorial, it depends on fastapi[standard], whose fastar and uvloop extras have no compatible distribution, and pywrangler sync stops with No solution found when resolving dependencies. Move the extras to the dev group so the local fastapi dev server keeps working:

uv remove fastapi
uv add fastapi
uv add --dev "fastapi[standard]"

Point wrangler at an entrypoint in a subdirectory

Create wrangler.jsonc at the project root:

wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "taskapi",
  "main": "src/worker.py",
  "compatibility_date": "2026-09-21",
  "compatibility_flags": ["python_workers"],
  "observability": { "enabled": true }
}

compatibility_date selects the Python version. Dates from 2026-09-08 onward run Python 3.14, dates from 2025-09-29 run 3.13, and earlier dates run 3.12. requires-python and .python-version govern only the local virtual environment, so a project pinned to 3.13 locally still runs on 3.14 in the Worker with the date shown.

Keep main in a subdirectory. Wrangler attaches every .py file beneath the entrypoint’s directory, and it does not skip .venv. With main.py at the project root, a deploy uploaded 858 files from .venv/ and totaled 23,274 KiB; moving the entrypoint to src/worker.py cut the upload to 8,602 KiB.

Any local application module worker.py imports must also live under src/. Dependencies continue to load from python_modules/, and the Workers runtime provides the workers module.

src/worker.py
from fastapi import FastAPI
from workers import asgi

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello from uv on Cloudflare Workers"}


Default = asgi.entrypoint(app)

asgi.entrypoint wraps any ASGI app, so the same last line works for Starlette or Litestar. Django and Flask use wsgi.entrypoint from the same module. No uvicorn or gunicorn runs in the Worker; the platform is the web server.

Vendor dependencies with pywrangler sync

Resolve and vendor the runtime dependencies:

$ uv run pywrangler sync
Downloading cpython-3.14.7-linux-x86_64-gnu (download) (34.3MiB)
Using CPython 3.14.7
Creating virtual environment at: .venv-workers
Downloading pyodide-3.14.2-emscripten-wasm32-musl (download) (7.2MiB)
Using CPython 3.14.2
Creating virtual environment at: .venv-workers/pyodide-venv
INFO     Resolved 11 requirements from /home/you/taskapi/pylock.toml.
INFO     Installing packages into python_modules...
INFO     Packages installed in python_modules.
INFO     Installing packages into .venv-workers...
INFO     Packages installed in .venv-workers.
SUCCESS  Sync process completed successfully.

uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first. By default, sync runs three uv commands: uv venv with a Pyodide interpreter (cpython-3.14.2-emscripten-wasm32-musl, which uv downloads like any other managed Python), uv pip compile pyproject.toml --no-build against PyPI plus the Pyodide package index to write pylock.toml, and uv pip install -r pylock.toml into that Pyodide environment. It then copies the installed site-packages to python_modules/. .venv-workers itself is a native Python 3.14 environment holding the same package versions, so local tools see what the Worker sees.

The project now has three new entries:

$ ls -a
.python-version  .venv  .venv-workers  pylock.toml  pyproject.toml  python_modules  src  uv.lock  wrangler.jsonc

Commit pylock.toml alongside uv.lock. It is the PEP 751 lockfile for the Worker, and later syncs use it as a constraint so versions do not drift. Ignore the two generated directories:

.gitignore
.venv-workers/
python_modules/

A FastAPI project vendors 11 packages into an 8.5 MB python_modules/. Nine wheels come from PyPI; pydantic and pydantic-core come from the Pyodide index, because the stable pydantic release pins a pydantic-core version that has no PyEmscripten wheel on PyPI.

Delete any leftover requirements.txt before syncing. pywrangler refuses to run while one exists and prints the dependencies = [...] block to move into pyproject.toml.

Run the Worker locally

dev and deploy both re-run sync when pyproject.toml or pylock.toml changed, so you rarely call sync by hand:

$ uv run pywrangler dev
INFO     Passing command to npx wrangler: npx --yes wrangler dev
 ⛅️ wrangler 4.136.1
⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787

The first dev run takes about 20 seconds while npx fetches wrangler and workerd starts. In another terminal:

$ curl http://localhost:8787/
{"message":"Hello from uv on Cloudflare Workers"}

FastAPI’s /docs page renders too. Local dev runs the Worker in workerd with the same Pyodide build, so a package that imports here imports in production.

Deploy the Worker

$ uv run pywrangler deploy
INFO     Passing command to npx wrangler: npx --yes wrangler deploy
 ⛅️ wrangler 4.136.1
Attaching additional modules:
│ Vendored Modules    │      │ 8601.32 KiB │
│ Total (369 modules) │      │ 8601.32 KiB │
Total Upload: 8601.63 KiB / gzip: 2135.44 KiB
Worker Startup Time: 2675 ms
Uploaded taskapi (15.98 sec)
Deployed taskapi triggers (0.58 sec)
  https://taskapi.<your-subdomain>.workers.dev

The first deploy opens a browser login if wrangler has no credentials. Cloudflare runs the Worker’s top-level scope at deploy time (the Worker Startup Time line) and snapshots the memory, so requests restore the snapshot instead of re-importing FastAPI. Five requests to the deployed endpoint from a laptop returned in 124 to 217 ms.

Stay inside the WebAssembly package set

Pure-Python wheels install from PyPI unchanged. A package with C or Rust extensions needs a wheel tagged for the PyEmscripten platform (standardized by PEP 783, Emscripten packaging) on PyPI, or a build in the Pyodide index. By default, pywrangler passes --no-build, so a package without a compatible wheel fails during sync with <package> has no usable wheels.

If a pure-Python dependency is available only as a source distribution or local directory, let pywrangler build it during sync, dev, and deploy:

pyproject.toml
[tool.pywrangler]
allow-build = true

This option cannot compile C or Rust extensions for WebAssembly. Those packages still need a PyEmscripten wheel or a Pyodide build.

The cibuildwheel reference covers building PyEmscripten wheels for your own packages.

The runtime has no threads, no subprocesses, and no filesystem that persists between requests. The platform caps memory at 128 MB per isolate and CPU time at 10 ms per request on the Free plan (30 seconds by default on Paid, configurable to 5 minutes). Compare those constraints with Lambda, Cloud Run, and Vercel in Running Python on Serverless.

Last updated on