- Home
- Skills
- Data & Databases
- Django ORM Lens: N+1, Migration Safety & Schema Map
More screenshots
Works with the AI Agents you already use
Django ORM Lens: N+1, Migration Safety & Schema Map
Your agent stops guessing about Django: real N+1 detection, migration-risk review, schema drift and "what breaks if I drop this field", from static analysis. No DB, no settings.
Free
Django ORM Lens: N+1, Migration Safety & Schema Map
Example session with this skill installed
We're about to merge the PR on branch pr (base master) in our Django shop. Is it safe to deploy? Also the order history page is slow — why?
- Read your context and instructions
- Compiled the django orm lens
Verdict: BLOCK
Don't merge this as it stands. Migration 0002 renames Product.title to name in the database, but models.py still declares title. After migrate, every query that touches Product fails, and that includes the order history page. It also adds a NOT NULL column with no default, which fails on any table that already has rows.
Scope checked: branch pr vs master (1 changed file: catalog/migrations/0002_product_weight_and_index.py), 2 apps, 4 models, 3 migrations replayed, all 13 source files scanned. Templates are not in the repo, so I couldn't check them for references.
| Severity | Where | What | Fix |
|---|---|---|---|
| critical (drift) | catalog/migrations/0002_product_weight_and_index.py:17 vs catalog/models.py:14 | RenameField title -> name in the migration, but the model still declares title. After migrate the column is name, yet Django still selects catalog_product.title. Every Product query errors out. | Remove the RenameField from this PR, or rename the Python attribute only (see below) |
| critical | catalog/migrations/0002_product_weight_and_index.py:7 | AddField weight_grams is NOT NULL with no default. The migration fails on any table that already has rows. | Add it as null=True, backfill, then enforce NOT NULL in a later release |
| warning | catalog/migrations/0002_product_weight_and_index.py:17 | The rename also breaks a rolling deploy: instances still on the old code query the old column | Rename the attribute only with db_column="title", or do expand/contract |
| warning | catalog/migrations/0002_product_weight_and_index.py:13 | AddIndex holds a SHARE lock on Postgres for the whole build: reads continue, every write to product blocks | AddIndexConcurrently in its own non-atomic migration |
| found by review | 0002...py:13 vs catalog/models.py | The index product_active_price_idx isn't in Product.Meta.indexes, so the next makemigrations will generate a RemoveIndex. No query filters or orders by price either. | Drop the index, or declare it in Meta.indexes |
pre-existing (also on master) | orders/models.py:20 | OrderItem.unit_price is declared but no migration creates it | Separate PR |
Good news: the PR does fix one earlier drift. Product.weight_grams was declared on master with no migration, and now it has one. It just needs a safe migration.
Safe rewrite of 0002
# catalog/migrations/0002_product_weight_grams.py
from django.db import migrations, models
def backfill_weight(apps, schema_editor):
Product = apps.get_model("catalog", "Product")
qs = Product.objects.filter(weight_grams__isnull=True)
while True:
ids = list(qs.values_list("pk", flat=True)[:1000])
if not ids:
break
Product.objects.filter(pk__in=ids).update(weight_grams=0)
class Migration(migrations.Migration):
dependencies = [("catalog", "0001_initial")]
operations = [
migrations.AddField(
model_name="product",
name="weight_grams",
field=models.PositiveIntegerField(null=True, blank=True),
),
migrations.RunPython(backfill_weight, migrations.RunPython.noop),
]
If you really want title called name, rename the Python attribute only. The column stays, so there's no data move and no rolling-deploy breakage:
# catalog/models.py
name = models.CharField(max_length=200, db_column="title")
# in the migration, state-only
migrations.SeparateDatabaseAndState(
state_operations=[migrations.RenameField("product", "title", "name")],
database_operations=[],
)
Update in the same commit (from impact title):
catalog/serializers.py:9:"title"inProductSerializer.Meta.fields(certain). Public API field: keep clients working withtitle = serializers.CharField(source="name").catalog/models.py:21:return self.title(likely)orders/views.py:13:item.product.title(likely)
If the index is needed, give it its own migration:
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [("catalog", "0002_product_weight_grams")]
operations = [
AddIndexConcurrently(
"product",
models.Index(fields=["is_active", "price"], name="product_active_price_idx"),
),
]
Still referenced
blast-radius looked up the new name name and reported 11 matches. I dismissed all of them: they are Category.name and name= keyword arguments in migrations, not Product. What actually breaks are the references to the old name title, listed above (1 certain, 2 likely).
Why the order history page is slow
orders/views.py:6 order_history is a nested N+1: one query for the orders, then for each order one query for items, and for each item one for product and one more for product.category.
Queries per request: 1 + N_orders + 2 x N_items. A customer with 20 orders of 3 items means
141 queries, growing with order history because the page isn't paginated.
| Confidence | Where | Accessed | Suggested |
|---|---|---|---|
| high | orders/views.py:9 | order.items | prefetch_related("items") |
| medium | orders/views.py:10 | item.product | select_related("product") |
| found by review | orders/views.py:14 | item.product.category | one more FK load per item |
Fix: one query
The page only renders item rows, so query OrderItem directly:
def order_history(request):
items = (
OrderItem.objects
.filter(order__customer=request.user)
.select_related("product__category")
.order_by("-order__created_at", "order_id", "pk")
)
rows = [
{
"order": item.order_id,
"product": item.product.title,
"category": item.product.category.name,
"qty": item.quantity,
}
for item in items
]
return render(request, "orders/history.html", {"rows": rows})
141 queries down to 1. Re-ran the scan on a copy with the fix: 2 findings before, 0 after.
Also found by review: recent_orders (orders/views.py:20) loops over Order.objects.filter(status="paid")[:50] and reads order.customer.email, which is 50 extra queries, and the slice has no order_by, so "recent" means any 50 rows:
for order in (Order.objects.filter(status="paid")
.select_related("customer")
.order_by("-created_at")[:50]):
Pin it with a test
def test_order_history_query_count(client, django_assert_num_queries, customer_with_orders):
client.force_login(customer_with_orders)
with django_assert_num_queries(3): # session + user + the one OrderItem query
client.get("/orders/history/")
Next steps (deploy order)
This PR. Take the RenameField out of 0002 (or switch to db_column / SeparateDatabaseAndState and update the serializer, __str__ and orders/views.py:13 in the same commit). Make weight_grams nullable with a backfill. Move the index to its own concurrent migration and declare it in Meta.indexes, or drop it.
2. Re-run the review: blast-radius --severity all --only <new migrations> and drift. Expect 0 critical and no blocking drift for catalog.
3. Separate PR. Fix order_history and recent_orders, add the query-count test.
4. Separate PR. Sort out the OrderItem.unit_price drift after checking the production schema.
5. Next release. Enforce NOT NULL on weight_grams once the backfill has run everywhere.
Connects securely to your tools. The creator never sees your data.
About this skill
Most Django skills are advice. This one ships an engine.
Ask your agent "is this migration safe to deploy?" and it normally skims a few files and guesses. With this skill it runs django-orm-lens, an open-source static analyzer (MIT, on PyPI, ~90★ on GitHub), across the whole project and answers from facts:
- Migration review that blocks real outages. 16 risk rules: NOT NULL column without a default,
AddIndexthat locks a Postgres table, renames that break rolling deploys, irreversibleRunPython, conflicting leaves. Each finding comes with the safe rewrite (three-step NOT NULL,AddIndexConcurrently, expand/contract). - Blast radius. For every destructive operation: the code that still reads the dropped column, grouped by Django layer (serializers, forms, admin, views, templates, tests) and tagged certain / likely / possibly.
- Schema drift.
makemigrations --checkwithout a settings module: fields declared in models.py that no migration creates — the column that will be missing at runtime. - N+1 detection. Loops over querysets that touch FK / M2M / reverse relations without
select_related/prefetch_related, with the exact fix — then the agent re-runs the scan to prove the fix worked. - Safe field / model removal.
impactlists every reference before you touch anything, and the skill turns it into a two-release deploy plan. - Cascade check. What deleting one row takes with it, walked recursively — "deleting a Category erases order history" before it happens.
- Schema map. ER diagrams (Mermaid, DBML, D2, PlantUML, Graphviz), model details, relations with
on_delete, signal graph, migration DAG, index suggestions from real query usage.
Safe on any repo. Pure AST parsing: it never imports your project, never needs DJANGO_SETTINGS_MODULE, secrets, installed dependencies or a database. Works on a fresh clone or a PR branch.
Works everywhere. Uses the django-orm-lens MCP server (13 tools) when connected, otherwise the CLI via uvx — zero install. Includes a one-shot audit.py that merges all analyzers into one verdict (BLOCK / FIX BEFORE DEPLOY / SHIP), with a --changed-since origin/main mode for PR review.
Honest by design. The skill tells the agent to check renames under the old name, verify name-matched references, walk cascades past the first level, look one level deeper than the N+1 scan, separate what a PR introduced from what was already broken, and say exactly what was checked instead of "no issues".
What's inside
SKILL.md— six playbooks (schema map, migration review, safe schema change, N+1, cascade, signals / migration history) and a report templatereferences/migration-playbook.md— all 16 rules with copy-paste safe migrations and deploy orderreferences/tools.md— every MCP tool and CLI command, MCP install lines for Claude Code, Cursor, VS Code, Codex, Gemini CLI, Windsurf, Claude Desktopscripts/audit.py— one-command audit
Built by the author of django-orm-lens (VS Code extension, CLI, MCP server). Source: https://github.com/FROWNINGdev/django-orm-lens
How to use
Skills on Agensi run inside your AI agent. Copy the prompt, paste it into your agent, and it connects to Agensi and installs this skill for you. If a payment is needed, your agent shows it to you first.
Reviews
No reviews yet
Be one of the first to try it. Every listed skill passes our trust checks below.
Security scanned
Passed our 8-point scan before listing
1 install
Downloaded by developers to date
Free forever
No account required to browse
Trust & safety
Security scanned
Verified clean today
- Free to download with an account
Requires