# 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](https://pydevtools.com/handbook/reference/uv.md) project by resolving `pyproject.toml`, writing a `pylock.toml` {{< term "lockfile" >}}, 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](https://pydevtools.com/handbook/tutorial/set-up-a-fastapi-project-with-uv.md) 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 {{< term "dev-dependency" "development dependency" >}}, keeping it out of production installs:

```bash
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:

```bash
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:

```json {filename="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.

```python {filename="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:

```console
$ 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:

```console
$ 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](https://pydevtools.com/handbook/explanation/what-is-pep-751.md) for the Worker, and later syncs use it as a constraint so versions do not drift. Ignore the two generated directories:

```gitignore {filename=".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:

```console
$ 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:

```console
$ 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

```console
$ 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](https://peps.python.org/pep-0783/), 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`:

```toml {filename="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](https://pydevtools.com/handbook/reference/cibuildwheel.md) 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](https://pydevtools.com/handbook/explanation/about-running-python-on-serverless.md).
