Cloudflare Python Workers Skill
Everything that is different, surprising, or error-prone when building Cloudflare Workers in Python instead of JavaScript/TypeScript. This skill does NOT duplicate general Cloudflare Workers documentation — it focuses on the Python-specific gaps.
Quick Decision Trees
"I need to set up a Python Worker"
New project?
├─ Quick start with pywrangler -> README.md
├─ Python-specific config flags -> configuration.md
├─ Add Python packages (Pyodide compat) -> configuration.md (Packages)
├─ Local dev secrets (.dev.vars) -> configuration.md (Local Development)
├─ Set up pytest with mock bindings -> patterns.md (Testing)
└─ Migrate from pre-Dec 2025 Worker -> gotchas.md (#1)
"I need to use a Cloudflare binding from Python"
Which binding?
├─ Any binding (D1, KV, Queues, etc.) -> api.md (Binding Access)
│ The API is the same as JS, BUT:
│ ├─ Results come back as JsProxy -> gotchas.md (#8) + patterns.md (FFI)
│ ├─ Dicts must use dict_converter -> gotchas.md (#5)
│ ├─ None = undefined ≠ null -> gotchas.md (#3)
│ └─ Convert at boundary, not inline -> patterns.md (FFI Boundary Layer)
├─ Durable Objects -> patterns.md (Durable Objects From Python)
│ ├─ Transactional storage -> patterns.md (DO Transactional Storage)
│ ├─ Alarms (scheduled wake-ups) -> patterns.md (DO Alarms)
│ ├─ WebSocket hibernation -> patterns.md (DO WebSocket Hibernation API)
│ └─ Outbound WebSocket client -> patterns.md (Outbound WebSocket Client)
├─ Workflows (multi-step) -> api.md (Workflows)
├─ Service Bindings / RPC -> api.md (Service Bindings)
│ └─ dict→Map serialization caveat -> api.md (Service Bindings note)
├─ Static Assets (special — no Worker!) -> patterns.md (Static Assets)
├─ Pass Python callable to JS API -> api.md (create_proxy)
└─ JS API rejects wrapped request/resp -> patterns.md (request.js_object)
"I need to cross the JS/Python boundary"
FFI task?
├─ Convert JS → Python -> api.md (FFI) — .to_py() method
├─ Convert Python → JS -> api.md (FFI) — to_js + dict_converter
├─ Type compatibility matrix -> api.md (FFI Type-Compatibility Matrix)
├─ None = undefined ≠ null -> gotchas.md (#3)
├─ Python bytes to R2/KV -> gotchas.md (#14) — to_js(bytes) for Uint8Array
├─ Binary Response (images/files) -> api.md (Binary Response)
├─ Deep/recursive conversion -> patterns.md (Safe JsProxy Conversion)
├─ Bidirectional boundary wrappers -> patterns.md (FFI — The boundary is bidirectional)
├─ D1 results are JsProxy -> patterns.md (D1 Row Conversion)
├─ Large binary data (>10MB) -> gotchas.md (#17) — bypass Python
└─ js.eval() is disallowed -> gotchas.md (#4)
"I need to test my Python Worker"
Testing task?
├─ Unit test with mock bindings -> patterns.md (Testing — Mock Bindings)
├─ The HAS_PYODIDE testability pattern -> patterns.md (Testing — HAS_PYODIDE)
├─ Test both HAS_PYODIDE branches -> patterns.md (Testing — Test both branches)
├─ Pyodide fakes for FFI testing -> patterns.md (Testing — Two-tier FFI testing)
├─ Mocks pass but prod fails (JsProxy) -> patterns.md (Testing — E2E)
├─ Mock D1 / Vectorize / AI / Queue -> patterns.md (Testing — Mock Bindings)
└─ Run tests (pytest) -> configuration.md (Test Setup)
"Something isn't working"
Common issues?
├─ D1 returns JsProxy not dict -> gotchas.md (#8) + patterns.md (FFI)
├─ Dict rejected by Cloudflare API -> gotchas.md (#5) — dict_converter
├─ None = undefined ≠ null in D1 -> gotchas.md (#3)
├─ Cold start too slow -> gotchas.md (#6) + patterns.md (Per-Isolate Init)
├─ PRNG fails at module level -> gotchas.md (#7)
├─ Package not found / won't install -> gotchas.md (#12)
├─ "python_workers flag required" -> configuration.md
├─ js.eval() disallowed -> gotchas.md (#4)
├─ R2/KV rejects Python bytes -> gotchas.md (#14) — to_js(bytes)
├─ Queue msg canceled on cold start -> gotchas.md (#15) — inherent behavior
├─ Queue msgs not delivered locally -> gotchas.md (#16) — use /process-now
├─ Worker crashes on large R2 objects -> gotchas.md (#17) — bypass Python
├─ Tests pass but prod fails -> patterns.md (Testing — E2E)
└─ No filesystem access for templates -> patterns.md (Jinja2 SSR)
Reference Files
All in references/python-workers/:
| File | Contents |
|---|---|
README.md |
Runtime overview, quick start, how Pyodide works, project structure |
api.md |
Python-specific handler signatures, FFI functions (to_js/to_py/create_proxy), binding access patterns, Response (including binary), Service Bindings (RPC + dict→Map note), Workflows, workers.fetch() |
configuration.md |
Python-specific config (compatibility flags, pyproject.toml, packages + confirmed working list, CPU limits, .dev.vars, rules for JS bundling, triggers.crons, test setup) |
patterns.md |
FFI boundary layer, D1 row conversion, queue message handling, Static Assets architecture, Durable Objects (storage, alarms, WebSocket hibernation, outbound WS), per-isolate initialization, request.js_object unwrapping, testing (3-tier with mocks), Jinja2 without filesystem, FastAPI ASGI bridge |
gotchas.md |
17 Python-specific issues with error signatures and fixes |
Anti-Patterns
Raw JsProxy in Business Logic
# NEVER — JsProxy leaks into app code, breaks serialization/iteration
results = await env.DB.prepare("SELECT * FROM feeds").all()
return results.results # JsProxy!
# ALWAYS — convert at the boundary
results = await env.DB.prepare("SELECT * FROM feeds").all()
rows = [dict(r) for r in results.results.to_py()] # Native Python
Python Dict to JS Without dict_converter
# NEVER — creates JS Map, most Cloudflare APIs reject it
js_obj = to_js({"topK": 50})
# ALWAYS — creates JS Object
js_obj = to_js({"topK": 50}, dict_converter=Object.fromEntries)
Module-Level PRNG
# NEVER — breaks Wasm snapshot, deployment FAILS
import random
random.seed(42)
# ALWAYS — inside handlers
class Default(WorkerEntrypoint):
async def fetch(self, request):
random.seed(42) # OK
Routing Static Assets Through the Worker
# NEVER — wakes up Python Worker for CSS/JS/images (1s+ cold start)
class Default(WorkerEntrypoint):
async def fetch(self, request):
if path.startswith("/static/"):
return await self.env.ASSETS.fetch(request) # Worker is already awake!
# ALWAYS — let Workers Static Assets serve directly from the edge
# Configure in wrangler.jsonc — requests to /static/ never reach your Python code
"assets": { "directory": "./assets/" }
# No binding needed. No Worker invocation. ~15-90ms TTFB.
Python bytes to Binary APIs
# NEVER — PyProxy, R2/KV will reject it
await env.BUCKET.put("key", my_bytes)
# ALWAYS — convert to Uint8Array
await env.BUCKET.put("key", to_js(my_bytes))