The Bottleneck Moved, It Didn't Disappear
There are four things you do when you change a mature web application. You work out how the existing system behaves. You decide what the change should be. You write it. You convince yourself it's safe. Writing was never the expensive one, and it's the only one an agent takes off your hands.
This is why the honest reports of AI-assisted development are so wildly inconsistent. On a greenfield CRUD app, where deciding is trivial and verifying means clicking through three pages, the speedup is enormous and the enthusiasm is real. On a nine-year-old Django monolith with a permissions model nobody has fully described in writing, the agent writes the diff in seconds and you spend two hours working out whether it respects an invariant that exists only in someone's head. Same tool, opposite outcome, and both camps think the other is lying.
So the useful question isn't "is AI good at Django?" — it demonstrably is, it has read more Django than any of us — but where in your work does generation actually sit on the critical path? Everything below follows from taking that question seriously.
A Blast-Radius Map
Delegation decisions get much easier when you stop asking "can it do this?" and start asking "what happens if it's subtly wrong and I don't notice for a month?" Two things drive that: how mechanical the task is, and how far a mistake travels.
| Task | Blast radius | How to work |
|---|---|---|
| Serializers, forms, admin classes, URL wiring | Low — wrong is visible immediately | Delegate whole. Skim the diff. |
| Test scaffolding, factories, fixtures | Low | Delegate, but write the assertions yourself. |
| Mechanical refactors with a stated rule | Low, if the rule is exact | Delegate. Let the test suite be the check. |
| Views, service functions, business logic | Medium — bugs reach users | Specify the edge cases up front, review properly. |
| Queries and anything performance-shaped | Medium — fine until real data | Delegate the draft, assert the query count. |
| Migrations, permissions, auth, money | High — irreversible or invisible | Draft with it, own every line yourself. |
| Data modelling, architecture, tradeoffs | Highest — outlives the code | Think first. Use it as a sounding board, not an author. |
The bottom two rows are where teams get hurt, and it's never because the model wrote nonsense. It's because it wrote something reasonable for a system it couldn't see all of. A schema is a decision you live with for years; an agent optimises the next few hundred tokens. Those are different objectives, and only one of them is yours.
The top rows are where the time actually comes back. A Django app is full of correct-by-inspection code that simply takes a while to type — a ModelAdmin with the right list_display and list_select_related, a DRF serializer with three nested read-only fields, sixty lines of factory definitions. Handing all of that over is a genuine, compounding win, and it costs nothing to verify because wrong is obvious at a glance.
Make the Repository the Prompt
An agent with no context writes the statistical average of all Django it has ever seen. That average is a 2019 tutorial: fat views, no service layer, filter() in templates, fields = "__all__" everywhere. If your codebase has opinions — and any codebase older than a year does — you have to make them readable, or you'll spend every review re-explaining the same five rules.
The highest-leverage file in an AI-assisted repo is a conventions file at the root. Claude Code reads CLAUDE.md; other tools have equivalents; the content matters far more than the filename. Keep it short enough that it stays true:
# CLAUDE.md
## Stack
Django 5.1 · DRF 3.15 · PostgreSQL 16 · Celery 5 · pytest-django · uv
## Architecture
- Business logic lives in `<app>/services.py`. Views validate input,
call a service, serialize the result. Views never touch the ORM directly.
- Models hold invariants and derived properties. No I/O in model methods.
- Celery tasks are thin: they take primitive args (never model instances),
load what they need, and call a service.
## Non-negotiable
- Every queryset in a view is scoped to `request.user` or its organisation.
There is no endpoint that fetches by pk alone.
- All timestamps use `django.utils.timezone.now`. `USE_TZ = True`.
- Multi-step writes go inside `transaction.atomic()`.
- DRF serializers list fields explicitly. Never `fields = "__all__"`.
- New models get a migration in the same commit. Never edit a merged migration.
## Testing
pytest, not unittest. `factory_boy` factories in `<app>/tests/factories.py`.
Every list endpoint gets an `assertNumQueries` test.
## Commands
`just test` · `just lint` · `just typecheck` — run all three before proposing a diff.
Two things make this work far better than it looks. First, state the prohibitions, not just the preferences — "never fields = '__all__'" removes an entire recurring class of review comment, permanently. Second, name the commands. An agent that can run your test suite will fix its own mistakes before you ever see them; an agent that can't is guessing.
Point at a file, not at an adjective
When you're asking for something new, the single most effective sentence in any prompt is a reference to code that already exists. "Add a refunds endpoint, following the pattern in orders/services.py and orders/api.py" produces output in your idiom. "Add a refunds endpoint, keep it clean and idiomatic" produces output in someone else's. One real example constrains generation more tightly than three paragraphs of description, because it pins down the things you'd never think to specify — how you name things, where the transaction boundary goes, whether errors are exceptions or result objects.
What Generated Django Actually Gets Wrong
After enough reviews the same defects recur, and they share a signature: the code runs, the happy path passes, and the bug is in something the model had no way to know. Here's the list I actually check against, in the order they've cost me time.
1. Querysets that aren't scoped to the user
This is the one that matters most and it looks completely innocent:
# Generated. Textbook DRF. Also an IDOR.
class InvoiceDetail(RetrieveAPIView):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated]
# What it needs to be
class InvoiceDetail(RetrieveAPIView):
serializer_class = InvoiceSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Invoice.objects.filter(
organisation=self.request.user.organisation
).select_related("customer")
IsAuthenticated checks that somebody is logged in. It says nothing about whether this somebody may see invoice 8412. The model produced the shape it has seen ten thousand times in tutorials, where multi-tenancy doesn't exist. Every authenticated test you write will pass. So will the pen test, unless someone thinks to increment an id.
There is no prompt that reliably prevents this, which is why it belongs in the conventions file and in a test you write once per endpoint: authenticate as user B, request user A's object, assert 404. See the Django security checklist for the wider version of this argument.
2. N+1 queries
Generated ORM code is written for readability, and readable ORM code is frequently O(n) in queries. A serializer with a nested field, a template loop over order.customer.name, a list comprehension over a reverse relation — each one is fine on the eight rows in your dev database and catastrophic on the eighty thousand in production.
Don't review for this. Assert it:
def test_invoice_list_query_count(client, organisation):
InvoiceFactory.create_batch(25, organisation=organisation)
client.force_login(organisation.owner)
# 1 session + 1 user + 1 invoices-with-joins. Nothing per-row.
with django_assert_num_queries(3):
response = client.get("/api/invoices/")
assert response.status_code == 200
django_assert_num_queries (the pytest-django fixture; self.assertNumQueries if you're on TestCase) is the single highest-value test you can put on a list endpoint. It turns an invisible performance regression into a red build, and it does it in a form the agent can read and fix without you. Create the batch with enough rows that a per-row query is unmistakable.
3. Serializers that leak
fields = "__all__" is a time bomb, not a bug. It's correct on the day it's written and wrong the day someone adds internal_notes, stripe_customer_id or risk_score to the model. The field appears in your public API with no code change, no review, and no test failure. Ban it in the conventions file and grep for it in CI.
4. Missing transaction boundaries
Generated service functions cheerfully perform four writes in sequence with no atomic block. Nothing fails in testing, because in testing nothing fails. In production the third call raises and you're left with a charged payment, an order marked paid, and no line items.
from django.db import transaction
def accept_quote(quote: Quote, user: User) -> Order:
with transaction.atomic():
quote.accept(by=user)
quote.save(update_fields=["status", "accepted_at", "accepted_by"])
order = Order.objects.create(quote=quote, organisation=quote.organisation)
OrderLine.objects.bulk_create(
OrderLine(order=order, **line.as_dict()) for line in quote.lines.all()
)
# Side effects go AFTER the transaction commits, never inside it.
notify_customer.delay(order.id)
return order
Note the last two lines. Queueing a Celery task inside atomic() is a race the agent will write for you every time: the worker can pick the job up before the transaction commits and fail to find the row. Either move it after the block or use transaction.on_commit().
5. Naive datetimes
datetime.now() instead of timezone.now(). It's a one-word difference, it passes review constantly, and with USE_TZ = True it produces warnings in dev and off-by-an-hour bugs in October. Mechanical, boring, endlessly repeated — exactly what a lint rule is for.
6. Confidently invented APIs
Method names that sound exactly right and don't exist; kwargs that were removed two Django versions ago; a third-party helper that lives in a different module now. This is the least dangerous category because it fails loudly the moment you run anything — which is the point. Run the code before you read it.
Migrations Deserve Their Own Paranoia
Migrations are the highest-blast-radius artefact in a Django project: they run once, against real data, usually while the site is up, and a bad one can take a table offline. An agent writes them from the model diff alone — it doesn't know your invoices table has forty million rows, and it can't know what your deploy does while the migration is running.
Always read the generated SQL. It takes ten seconds:
python manage.py sqlmigrate invoices 0042
Three things to look for specifically:
- A new column with a default. On modern PostgreSQL a constant default is cheap, but a volatile one still rewrites the table. Adding nullable and backfilling in batches is the boring safe path.
- An index built in-line.
CREATE INDEXtakes a lock that blocks writes for the duration. On a large table useAddIndexConcurrentlyfromdjango.contrib.postgres.operations, withatomic = Falseon the migration class — which the agent will forget, because the naive version is what the autogenerator produces. - A
RunPythonwith noreverse_code. You've just made the deploy one-way. If the data change genuinely can't be reversed, passmigrations.RunPython.noopdeliberately, so it's a decision rather than an omission.
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False # required: CONCURRENTLY cannot run in a transaction
dependencies = [("invoices", "0041_invoice_risk_score")]
operations = [
AddIndexConcurrently(
model_name="invoice",
index=models.Index(
fields=["organisation", "-issued_at"],
name="invoice_org_issued_idx",
),
),
]
And one rule with no exceptions: never let an agent edit a migration that has already been applied anywhere. It will happily "tidy" one, and the resulting divergence between your migration history and production's is a genuinely miserable afternoon. Say it in the conventions file.
Tests Are the Interface, Not the Chore
The instinct is to generate the code and then ask for tests. That ordering is backwards, and it produces the most useless artefact in the genre: tests written against the implementation, asserting that the code does what the code does. They pass forever, including when the behaviour is wrong.
Invert it. Write the assertions first — in prose or in code — and let those be the specification. They're doing three jobs at once: telling the agent what "done" means, giving it a signal it can iterate against without you, and leaving behind the only durable evidence that the feature works.
# Hand the agent this file, then ask for the implementation.
import pytest
pytestmark = pytest.mark.django_db
class TestAcceptQuote:
def test_creates_order_with_all_quote_lines(self): ...
def test_is_idempotent_when_quote_already_accepted(self): ...
def test_rolls_back_entirely_if_line_creation_fails(self): ...
def test_rejects_quote_belonging_to_another_organisation(self): ...
def test_rejects_expired_quote(self): ...
def test_notifies_customer_only_after_commit(self): ...
Those six names are the entire design conversation. Idempotency, rollback, tenancy, expiry, commit ordering — every one of them is something the model would not have inferred and would not have handled, and stating them costs a minute. The implementation that comes back is dramatically better than the one you'd get from "add an accept-quote endpoint", not because the model got smarter but because you specified the job.
Two guardrails on the generated tests themselves. Never accept a test you haven't seen fail. A test asserting the wrong thing is worse than no test, because it launders a bug into a guarantee — comment out the implementation, watch it go red, put it back. And watch for over-mocking: agents reach for mock.patch to make things pass, and a test that mocks the ORM asserts only that Python calls the functions you told it to. For async code specifically, the patterns in testing async Django code apply unchanged — an agent will reach for a plain TestCase around a Celery task and produce something that passes while testing nothing.
Automate the Review You'd Otherwise Repeat
Every defect in section four is mechanical. If you're catching them by reading diffs, you're spending scarce attention on work a linter does better, and you'll miss one on a Friday. The correct response to "the agent keeps doing X" is almost never a better prompt — it's a check that makes X impossible to merge.
# .github/workflows/ci.yml (the parts that matter here)
- run: ruff check . && ruff format --check .
- run: mypy . # with django-stubs; catches invented APIs
- run: pytest --cov --cov-fail-under=85
- run: python manage.py makemigrations --check --dry-run # model/migration drift
- run: pip-audit # generated deps are often outdated
- run: |
! grep -rn 'fields = ."__all__"' --include='*.py' . \
|| (echo "::error::explicit serializer fields only" && exit 1)
mypy with django-stubs earns its keep here more than in any hand-written codebase, because hallucinated attributes and wrong-typed kwargs are precisely what it's built to catch, and they're precisely what generation produces. makemigrations --check catches the model edited without a migration. pip-audit matters because models suggest the version of a library that dominated their training data, which is by definition not the newest one — and supply-chain drift is how that becomes your problem.
Give the agent these commands and let it run them. The loop where it writes, runs the suite, sees a failure and fixes it is the one that actually saves you time; the loop where you are the test runner is not.
The Workflow That Holds Up
All of the above assembles into something fairly simple.
- Decide what you're building yourself. Data model, endpoint shape, invariants, failure behaviour. This is the part that is genuinely your job, and it's ten minutes.
- Write the test names. Prose is fine. This is where edge cases enter the system — the agent will not invent your business rules.
- Point at the pattern to follow. A named file beats any adjective.
- Let it write, run, and iterate against the suite until green, without you in the loop.
- Read the diff as a diff. Not as prose — hunk by hunk, against the checklist below. Watch for scope creep: agents love to reformat an adjacent function or "improve" something you didn't ask about, and a 400-line diff for a 40-line feature is a diff nobody reviews properly.
- Run it. Actually exercise the endpoint. The happy path passing in tests and the feature working are different claims.
- Commit small. One logical change per commit, with a message that says why. This matters more than it used to: in six months
git blameis the only record of intent, and "AI wrote it" is not an explanation you can hand a colleague.
The thing that doesn't show up for months
A real cost, worth naming plainly: reviewing code teaches you far less than writing it. If every non-trivial piece of your system arrived by generation, your mental model of that system is thinner than it would otherwise be, and you find that out at 3am during an incident when there's no agent-shaped path from symptom to cause.
I don't think the answer is to refuse the tool. It's to keep ownership of the parts you'll need to reason about under pressure — the data model, the money, the auth, the async boundaries — and to occasionally read the generated code properly rather than only checking whether it's wrong. Understanding is a separate activity from verification, and only one of the two is on the critical path today.
Review Checklist
What I actually run through on a generated Django diff:
- Is every queryset scoped to the request user or organisation? Fetching by
pkalone is an IDOR until proven otherwise. - Does the endpoint have a query-count test? N+1 is invisible on dev data and unmissable on real data.
- Are serializer fields listed explicitly? No
__all__, ever — it leaks the column someone adds next year. - Is every multi-step write inside
transaction.atomic(), with side effects and task dispatch after commit? - Read the migration SQL. Table rewrite, blocking index, missing
reverse_code. Never edit an applied migration. timezone.now(), notdatetime.now(). Every time.- Have you seen each new test fail? A green test you've never seen red proves nothing.
- Is it over-mocked? Patched ORM calls test your patches.
- Any new dependency — is it real, maintained, and current? Suggested versions skew old.
- Is the diff the size of the feature? Unasked-for refactors ride along inside otherwise-good changes.
- Do you understand it well enough to debug it at 3am? If not, that's the part to rewrite yourself.
None of this is exotic. It's ordinary engineering discipline, applied to a collaborator who is fast, widely read, extremely confident, and has never once seen your production database. The teams getting the most out of this aren't the ones prompting most cleverly — they're the ones whose repositories make the right thing easy to generate and the wrong thing impossible to merge.