Skip to content

Python 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.

  • An AGENTS.md rule block for typed, async Python that all three tools follow, and a make check gate 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.

  1. 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.14
    uv add fastapi "pydantic-settings" "sqlalchemy[postgresql-asyncpg]" alembic structlog httpx
    uv add --dev pytest pytest-asyncio pytest-cov "testcontainers[postgres]" respx ruff mypy

    Current releases on PyPI on 2026-09-26: fastapi 0.141.1, pydantic 2.13.5, pydantic-settings 2.15.0, sqlalchemy 2.1.1, alembic 1.20.0, pytest 9.1.1, pytest-asyncio 1.4.0, ruff 0.16.9, mypy 2.3.1, testcontainers 4.15.0, celery 5.6.3, django 6.1.1. The [postgresql-asyncpg] extra matters: SQLAlchemy 2.1 no longer installs greenlet by default, and the async extension cannot run without it.

  2. Configure the tools in pyproject.toml. Two settings do most of the catching: the ASYNC rules flag blocking calls inside async def, and filterwarnings = ["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 = true
    plugins = ["pydantic.mypy"]
    [tool.pytest.ini_options]
    asyncio_mode = "auto"
    asyncio_default_fixture_loop_scope = "function"
    filterwarnings = ["error"]
  3. 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 app
    uv run pytest --cov=app --cov-fail-under=80
    uv export --locked --no-dev --no-emit-project --format requirements.txt -o .audit.txt
    uvx pip-audit --disable-pip -r .audit.txt

    The last two lines audit the locked runtime dependencies without installing them. Add .audit.txt to .gitignore.

  4. Put the Python rules in AGENTS.md at the repository root. Codex reads AGENTS.md natively; 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(). Relationships
    use 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 use
    Testcontainers Postgres, not SQLite and not mocks.
    - Never edit an existing test assertion, add pytest.mark.skip or xfail, or
    loosen filterwarnings to make a test pass. If a test looks wrong, stop and
    say 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.

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.md

Format 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):

Terminal window
uv tool install pyright
claude plugin install pyright-lsp@claude-plugins-official

Before you open a pull request, run /code-review in the session for a second opinion on the diff.

Start each tool on its default model and raise the effort setting before you switch models; the models hub lists the current defaults.

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):

Terminal window
claude mcp add --transport http context7 https://mcp.context7.com/mcp

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:

@asynccontextmanager
async 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.

GateCommandWhat it catches
Format and lintruff format --check, ruff check with ASYNC, S, B, UPBlocking calls in async def, hard-coded secrets, SQL built from strings, outdated syntax
Typesmypy --strict with the Pydantic pluginWrong shapes between layers, unhandled None, missing awaits on typed coroutines
Deprecationsfilterwarnings = ["error"]Pydantic v1 idioms, removed pytest-asyncio patterns, deprecated imports
Unit and HTTP testspytest with httpx.AsyncClient and respxStatus codes, error bodies, validation, retries, redaction
Integration testsTestcontainers Postgres and RedisRaces such as overselling, missing constraints, redelivered Celery tasks, tenant leaks
Coverage floorpytest --cov-fail-under=80Whole modules the agent never tested
Dependenciespip-audit on the locked exportKnown 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()) inside async def. Recovery: enable ruff’s ASYNC rules if they are off, then prompt: “Fix every ASYNC finding with an async equivalent, or move the call to asyncio.to_thread with a comment saying why. Done means make check passes.”
  • MissingGreenlet in production only. Symptom: sqlalchemy.exc.MissingGreenlet on an endpoint that worked in tests. Cause: an implicit lazy load of a relationship in async code, often in a response serializer. Recovery: set lazy="raise" on every relationship, run the tests, and add selectinload() wherever one now fails.
  • Async SQLAlchemy fails at startup after an upgrade. Symptom: an error saying the greenlet library is required. Cause: SQLAlchemy 2.1 moved greenlet into the [asyncio] extra. Recovery: depend on sqlalchemy[postgresql-asyncpg] (or sqlalchemy[asyncio]) and commit the updated uv.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_loop fixture. Cause: the agent wrote the pre-1.0 pattern of overriding event_loop, which pytest-asyncio 1.0 removed. Recovery: run the legacy-idiom cleanup prompt above; it replaces the fixture with loop_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 skip or xfail, or an edited filterwarnings in the same diff as the fix. Recovery: revert the test file, restate the AGENTS.md rule, and make CI fail when a pull request changes an existing test_*.py file or the pytest configuration without a test-change label.

Did this guide help you?

One tap, no sign-in.

not at all a lot