Python Workflow Recipes
PLPython backend recipes for AI coding agents are prompts plus verification gates that make Claude Code, Codex, and Cursor produce production-ready FastAPI and Django services: validated settings, Pydantic v2 schemas, async SQLAlchemy 2.1 that holds under concurrency, idempotent Celery tasks, resilient HTTP clients, and redacted logs. You accept the output on gate evidence, not on line-by-line reading.
Your FastAPI service passes every test the agent wrote. Then the first load test freezes the event loop because a handler calls requests.get(), an async query raises MissingGreenlet on a relationship nobody loaded, and a retried Celery task charges a customer twice. The code looked idiomatic, and the tests ran against SQLite with mocks, so they never had a chance to catch any of it.
This page is for developers who build Python backends with an agent and want to stop reviewing every handler by eye. It puts the production details into the prompts, then makes the machine prove them.
What these Python recipes give you
Section titled “What these Python recipes give you”- An
AGENTS.mdrule block for typed, async Python that all three tools follow, and amake checkgate the agent runs before it says “done” - Eight copy-paste prompts: a FastAPI skeleton with lifespan and settings, Pydantic v2 validation, an async SQLAlchemy repository that cannot oversell, idempotent Celery tasks, an httpx client with timeouts and retries, structlog with redaction, a Django REST Framework API with tenant scoping, and a legacy-idiom cleanup
- A verification table that tells you which gate catches which bug, so you read gate output instead of the diff
- Recovery steps for the seven ways agent-written Python most often breaks
Set up Python conventions and a gate the agent must pass
Section titled “Set up Python conventions and a gate the agent must pass”Two artifacts do most of the work: a rules file the agent reads on every task, and one command that fails when the code is not ready. Write both before you ask for any code.
-
Pin the interpreter and the toolchain. Python 3.14 is the current stable line, and PEP 790 schedules Python 3.15.0 for 2026-10-01. Pin 3.14 until your dependencies declare 3.15 support. With uv (terminal):
Terminal window uv python pin 3.14uv add fastapi "pydantic-settings" "sqlalchemy[postgresql-asyncpg]" alembic structlog httpxuv add --dev pytest pytest-asyncio pytest-cov "testcontainers[postgres]" respx ruff mypyCurrent releases on PyPI on 2026-09-26:
fastapi0.141.1,pydantic2.13.5,pydantic-settings2.15.0,sqlalchemy2.1.1,alembic1.20.0,pytest9.1.1,pytest-asyncio1.4.0,ruff0.16.9,mypy2.3.1,testcontainers4.15.0,celery5.6.3,django6.1.1. The[postgresql-asyncpg]extra matters: SQLAlchemy 2.1 no longer installsgreenletby default, and the async extension cannot run without it. -
Configure the tools in
pyproject.toml. Two settings do most of the catching: theASYNCrules flag blocking calls insideasync def, andfilterwarnings = ["error"]turns every deprecation warning, including Pydantic v1 idioms, into a failing test.[tool.ruff]target-version = "py314"[tool.ruff.lint]select = ["E", "F", "I", "B", "UP", "ASYNC", "S", "RUF"][tool.ruff.lint.per-file-ignores]"tests/**" = ["S101"][tool.mypy]strict = trueplugins = ["pydantic.mypy"][tool.pytest.ini_options]asyncio_mode = "auto"asyncio_default_fixture_loop_scope = "function"filterwarnings = ["error"] -
Add the gate. It is the definition of done for every prompt on this page. In a
Makefile(recipe lines start with a tab):check:uv run ruff format --check .uv run ruff check .uv run mypy appuv run pytest --cov=app --cov-fail-under=80uv export --locked --no-dev --no-emit-project --format requirements.txt -o .audit.txtuvx pip-audit --disable-pip -r .audit.txtThe last two lines audit the locked runtime dependencies without installing them. Add
.audit.txtto.gitignore. -
Put the Python rules in
AGENTS.mdat the repository root. Codex readsAGENTS.mdnatively; the tabs below show how Claude Code and Cursor pick up the same text.## Python conventions- Python 3.14, uv, fully typed. mypy --strict must pass; no bare `Any`,no `# type: ignore` without a reason in the same comment.- Pydantic v2 only: model_config = ConfigDict(...), @field_validator,@model_validator, model_dump(). Never class Config, @validator, or .dict().- Settings come from one pydantic-settings class in app/core/config.py.No os.getenv or os.environ anywhere else.- Inside async def: no requests, no time.sleep, no sync SQLAlchemy sessions,no blocking file I/O. Use httpx.AsyncClient, asyncio.sleep, AsyncSession.- SQLAlchemy 2.x style: select(), Mapped[], mapped_column(). Relationshipsuse lazy="raise"; load them explicitly with selectinload().- Every outbound HTTP call has an explicit timeout.- Tests: pytest with pytest-asyncio in auto mode. Database tests useTestcontainers Postgres, not SQLite and not mocks.- Never edit an existing test assertion, add pytest.mark.skip or xfail, orloosen filterwarnings to make a test pass. If a test looks wrong, stop andsay why.- Done means `make check` passes. Paste its last 20 lines in your final message.
The last two rules matter most. The first stops the agent from weakening your tests to reach green; see protect the oracle for why that is the most common way agent work fakes success. The second turns “I think it works” into evidence you can read in ten seconds.
Wire the rules and gate into each tool
Section titled “Wire the rules and gate into each tool”The rules and the prompts are identical across tools. What differs is how each tool loads the rules, formats edits, and reviews the result.
Claude Code reads CLAUDE.md first, so create one that imports the shared file. The @path syntax expands the file into context at launch:
@AGENTS.mdFormat and auto-fix each Python file the agent edits with a PostToolUse hook in .claude/settings.json:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | grep -E '\\.py$' | xargs -r sh -c 'uv run ruff format \"$@\" && uv run ruff check --fix \"$@\"' _" } ] } ] }}Give the agent go-to-definition and type diagnostics through the pyright-lsp plugin from Anthropic’s official marketplace. The plugin registers the server only, so install Pyright first (terminal):
uv tool install pyrightclaude plugin install pyright-lsp@claude-plugins-officialBefore you open a pull request, run /code-review in the session for a second opinion on the diff.
Codex reads AGENTS.md directly, so no extra file is needed. Run a task with workspace writes and approval prompts for anything wider (terminal):
codex -s workspace-write -a on-request "Implement POST /api/v1/orders as described in docs/tasks/orders.md. Done means make check passes."Review the branch against main before you push it:
codex review --base mainCreate a project rule in Cursor’s Rules settings with the same list, scoped to *.py files, so the agent loads it whenever it touches Python. Use Plan Mode for the multi-file prompts on this page so you approve the file list before any code is written. Run independent recipes in Worktrees, and let Bugbot review the pull request.
Start each tool on its default model and raise the effort setting before you switch models; the models hub lists the current defaults.
Give the agent current library docs
Section titled “Give the agent current library docs”The Python stack on this page moved faster than most training data: FastAPI dropped pydantic.v1 support in 0.128.0 (2025-12-27), pytest-asyncio 1.0 removed the event_loop fixture, and SQLAlchemy 2.1 changed its async install. The Context7 MCP server serves version-specific docs to the agent. Its API key is optional, so the plain remote URL works (terminal):
claude mcp add --transport http context7 https://mcp.context7.com/mcpcodex mcp add context7 --url https://mcp.context7.com/mcpAdd a server named context7 with the URL https://mcp.context7.com/mcp in .cursor/mcp.json or in Cursor’s MCP settings.
Then end a prompt with “use context7 for SQLAlchemy 2.1 and pytest-asyncio 1.x”. Each MCP server adds its tool definitions to the context, so connect it in the projects that need it rather than globally.
Build a production FastAPI service with an agent, step by step
Section titled “Build a production FastAPI service with an agent, step by step”Each step is one prompt and the check that proves it. Run them in order: later prompts assume the skeleton, the settings module, and the database module exist.
How do you scaffold a FastAPI service that starts and stops cleanly?
Section titled “How do you scaffold a FastAPI service that starts and stops cleanly?”The most common first-pass omissions are startup validation and shutdown. The agent reads environment variables wherever it needs them and creates the database engine at import time, so a missing variable surfaces as a 500 on the first request and the engine is never disposed.
A good answer has this core:
@asynccontextmanagerasync def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() engine = create_async_engine(str(settings.DATABASE_URL), pool_pre_ping=True) app.state.sessionmaker = async_sessionmaker(engine, expire_on_commit=False) yield await engine.dispose()ASGITransport alone does not run the lifespan, which is why the prompt names LifespanManager (PyPI asgi-lifespan). Without it, tests that pass may never have created the engine they claim to exercise.
How do you get Pydantic v2 validation that rejects bad input precisely?
Section titled “How do you get Pydantic v2 validation that rejects bad input precisely?”Agents trained on years of Pydantic v1 code still write class Config, @validator, and .dict(). The filterwarnings = ["error"] setting makes each of those a failing test, because Pydantic v2 emits a deprecation warning for them.
Asserting on loc and type, not on the message text, keeps the tests stable across Pydantic releases while still failing when a rule disappears.
How do you get async SQLAlchemy queries that hold under concurrent orders?
Section titled “How do you get async SQLAlchemy queries that hold under concurrent orders?”The agent’s first attempt at “check stock, then decrement” loads the product, compares in Python, and writes it back. Two concurrent orders both read stock = 1 and both succeed. SQLite with a single connection cannot show this bug; only Postgres under concurrent sessions can.
lazy="raise" turns the classic async bug, an implicit lazy load that fails with MissingGreenlet in production, into an immediate error in the first test that touches the relationship. The import path matters too: in testcontainers 4.15.0 the old testcontainers.postgres module emits a deprecation warning, which your filterwarnings setting turns into a failure.
To let the agent read your real schema instead of guessing column names, connect a database MCP server to a development database with a read-only role. The database MCP guide compares the servers and their read-only switches. For migration safety on large tables, see database migration patterns.
How do you move slow work to Celery without duplicate side effects?
Section titled “How do you move slow work to Celery without duplicate side effects?”Celery retries are only safe when the task is idempotent. With acks_late, a worker crash or a task that outlives the Redis broker’s visibility timeout gets the task delivered again, and the second run repeats every side effect.
Two details the agent tends to miss. celery.contrib.pytest is not registered as a pytest plugin automatically, so its fixtures do not exist until conftest.py names it. And task_always_eager runs the task inside the test process, so it cannot show broker redelivery or worker loss; keep eager mode for unit tests of the task body only. On Django 6, the built-in django.tasks framework defines and queues tasks, but running them in the background still needs a third-party worker backend; the built-in backends only run tasks immediately or discard them.
How do you call external APIs without freezing the event loop?
Section titled “How do you call external APIs without freezing the event loop?”Agent-written clients fail in three ways: a synchronous requests call inside async def, no timeout, or a retry loop that retries a 400 forever. Ruff’s ASYNC rules catch the first one in the gate; the prompt handles the other two.
The “not retried” cases are the ones that matter. An implementation that retries everything passes every success-path test and multiplies refunds in production.
How do you get structured logs that never leak secrets?
Section titled “How do you get structured logs that never leak secrets?”The concurrent-requests assertion proves the context variables are cleared and scoped per request. A redaction processor that is never tested fails silently when the key names drift.
How do you keep a Django REST Framework API tenant-safe?
Section titled “How do you keep a Django REST Framework API tenant-safe?”In a multi-tenant Django app, the dangerous bug is one viewset that forgets to filter by organisation. It passes every test written from the owner’s point of view.
The route-iterating test is the gate that scales: when the agent adds a new viewset next month and forgets the mixin, the suite fails without anyone reading the new file.
How do you clean up legacy Python idioms an agent keeps reintroducing?
Section titled “How do you clean up legacy Python idioms an agent keeps reintroducing?”Run this prompt once on an existing codebase before you start the recipes, and again whenever the deprecation gate turns red after a dependency bump.
How do you verify agent-written Python without reading every line?
Section titled “How do you verify agent-written Python without reading every line?”Read the gate output and the test-case lists, not the diff. Each gate below catches a class of bug that looks fine in review.
| Gate | Command | What it catches |
|---|---|---|
| Format and lint | ruff format --check, ruff check with ASYNC, S, B, UP | Blocking calls in async def, hard-coded secrets, SQL built from strings, outdated syntax |
| Types | mypy --strict with the Pydantic plugin | Wrong shapes between layers, unhandled None, missing awaits on typed coroutines |
| Deprecations | filterwarnings = ["error"] | Pydantic v1 idioms, removed pytest-asyncio patterns, deprecated imports |
| Unit and HTTP tests | pytest with httpx.AsyncClient and respx | Status codes, error bodies, validation, retries, redaction |
| Integration tests | Testcontainers Postgres and Redis | Races such as overselling, missing constraints, redelivered Celery tasks, tenant leaks |
| Coverage floor | pytest --cov-fail-under=80 | Whole modules the agent never tested |
| Dependencies | pip-audit on the locked export | Known vulnerabilities in runtime dependencies |
| Review agent | /code-review (Claude Code), codex review --base main (Codex), Bugbot (Cursor) | Logic errors and missing cases the gates cannot express |
Coverage proves that lines ran, not that the assertions are strong. For stronger evidence that the tests themselves are good, see mutation testing and oracle strength.
Run the gate in CI on every pull request; CI/CD pipeline patterns has the workflow file. The engineer who owns the service signs off on three things: the test-case lists the agent wrote before the code, a green gate, and the concurrency and permission-matrix results. Human reading goes to the hunks the review agent flags and to code that touches authentication, money, or tenant boundaries. Agent PR review covers how to split that work across a team, and agent security gates adds an independent security reviewer.
When agent-written Python breaks in production
Section titled “When agent-written Python breaks in production”Each failure below passes a happy-path test suite. Each has a symptom you can detect and a recovery you can apply.
- The event loop stalls under load. Symptom: p99 latency jumps for every endpoint when one slow upstream is called, while CPU stays low. Cause: a blocking call (
requests,time.sleep, a sync ORM session,open()) insideasync def. Recovery: enable ruff’sASYNCrules if they are off, then prompt: “Fix every ASYNC finding with an async equivalent, or move the call toasyncio.to_threadwith a comment saying why. Done means make check passes.” MissingGreenletin production only. Symptom:sqlalchemy.exc.MissingGreenleton an endpoint that worked in tests. Cause: an implicit lazy load of a relationship in async code, often in a response serializer. Recovery: setlazy="raise"on every relationship, run the tests, and addselectinload()wherever one now fails.- Async SQLAlchemy fails at startup after an upgrade. Symptom: an error saying the
greenletlibrary is required. Cause: SQLAlchemy 2.1 movedgreenletinto the[asyncio]extra. Recovery: depend onsqlalchemy[postgresql-asyncpg](orsqlalchemy[asyncio]) and commit the updateduv.lock. - Overselling or double side effects. Symptom: negative stock, duplicate reports, or duplicate refunds after a spike or a worker restart. Cause: read-then-write logic in Python, or a non-idempotent task that was redelivered. Recovery: add the concurrent Testcontainers test from the order recipe, then prompt: “Find every place that reads a value, decides in Python, and writes it back. Replace each with a conditional UPDATE or a row lock, and add a concurrent test for each.”
- The test suite errors after a pytest-asyncio upgrade. Symptom: failures mentioning the
event_loopfixture. Cause: the agent wrote the pre-1.0 pattern of overridingevent_loop, which pytest-asyncio 1.0 removed. Recovery: run the legacy-idiom cleanup prompt above; it replaces the fixture withloop_scope. - One tenant sees another tenant’s data. Symptom: a support ticket, or an access log showing IDs from another organisation. Cause: a new viewset or query without the tenant filter. Recovery: add the route-iterating mixin test from the Django recipe so the gap cannot recur, and audit logs for the affected window.
- The agent weakens a test to reach green. Symptom: a loosened assertion, a deleted case, an added
skiporxfail, or an editedfilterwarningsin the same diff as the fix. Recovery: revert the test file, restate theAGENTS.mdrule, and make CI fail when a pull request changes an existingtest_*.pyfile or the pytest configuration without atest-changelabel.
Where to go next with Python services
Section titled “Where to go next with Python services”- Make the repository agent-ready first, if it is not yet: agent-ready codebase.
- Design the API contract before the handlers: API development patterns.
- Harden the data layer: ORM best practices and database migration patterns.
- Build pipelines and batch jobs in Python: data engineering recipes.
- Ship the service: Docker patterns and monitoring and logging patterns.
- Decide what the agent may run unattended: permissions and sandboxing.
- Compare with another backend stack: Node.js/Express recipes, or return to the backend recipes overview.
Did this guide help you?
One tap, no sign-in.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in