FROWNINGdev · 31 October 2026
I maintain orm-lens (until
recently django-orm-lens), a static analyzer for Django models and
migrations. One of its checks,
migration-risk, reads every migration file and flags operations that break
on a table that already has rows: a NOT NULL column with no default, a
UNIQUE column that existing duplicates will violate, an index build that
locks the table.
Its test suite was green, so I pointed it at real projects:
| Project | GitHub stars | Critical findings |
|---|---|---|
| NetBox | 21.8k | 212 |
| PostHog | 40.5k | 79 |
| authentik | 26.2k | 58 |
| Healthchecks | 10.5k | 27 |
| Plane | 61.7k | 25 |
| paperless-ngx | 47.4k | 6 |
That is 407 critical findings. I read every one, and two were real.
The other 405 were not edge cases. Each came from something Django does when it migrates a populated table, which I had modelled wrongly. Here they are, ordered by how many false alarms each caused.
class Migration(migrations.Migration):
replaces = [("dcim", "0002_auto_..."), ..., ("dcim", "0059_...")]
A squashed migration only runs on a fresh database. On a database where the
replaced migrations are already applied, Django marks the squash as applied
without running it. On a database where only some of them are applied, it
keeps running the originals. Either way, every AddField inside a squash
lands on an empty table. Squashes alone made up more than half of all the
alarms.
blank=True text fields get "" (83)migrations.AddField("virtualmachine", "serial",
models.CharField(blank=True, max_length=50))
No default= and no null=True, so the column is NOT NULL, yet this is
safe. BaseDatabaseSchemaEditor._effective_default() fills existing rows with
"" for any blank=True field whose class allows empty strings. Projects
also subclass CharField: NetBox’s ColorField(blank=True, max_length=6) gets
the same treatment. The linter now treats a max_length argument as a sign of
a text field.
ManyToManyField, django-taggit’s TaggableManager and GenericRelation
create a join table, or nothing at all. They are never a NOT NULL risk on
the model’s own table.
makemigrations splits foreign keys out of CreateModel (25)migrations.CreateModel(name="ContactGroupMembership", fields=[...]),
...
migrations.AddField("contactgroupmembership", "group",
models.ForeignKey(..., on_delete=models.CASCADE)),
To break dependency cycles, the autodetector creates the table first and adds some foreign keys afterwards, in the same migration. That table was created a few lines earlier, so it is empty.
Django refuses to migrate an app whose migration graph has two leaf nodes. The check flagged authentik’s apps as conflicting because the squash and the last migration it replaces both looked like leaves. The loader drops the replaced nodes and points their dependents at the squash, so the linter now does the same before it counts leaves.
auto_now, db_default and GeneratedField fill themselves (16)auto_now_add=True gets the current time on ADD COLUMN, and db_default
is a database-side default. A GeneratedField is computed by the database.
None of them needs default=.
models.CharField(blank=True, max_length=128, null=True, unique=True)
Existing rows get NULL. On PostgreSQL, MySQL and SQLite a unique constraint
never treats two NULLs as equal, so this is safe. The same holds for
default=None, which authentik uses on four fields across three migrations.
A RemoveField followed by an AddField of the same name, or a
RenameField onto it, leaves the field in place. So does a DeleteModel
followed by a CreateModel. The linter compared each historical removal
with today’s models.py, saw the field still there, and called it drift.
authentik adds a NOT NULL column right after a RunPython that, its comment
says, deletes every row. A static tool cannot prove what a RunPython does,
so this is now a warning that says why, rather than a certain critical.
PostHog’s 0308_add_indirect_person_override_constraints removes two
foreign-key columns and adds them back as NOT NULL with no default:
migrations.RemoveField(model_name="personoverride", name="old_person_id"),
migrations.AddField(model_name="personoverride", name="old_person_id",
field=models.ForeignKey(on_delete=..., to=...)),
On a populated personoverride table this fails. The table may well have
been empty when it shipped. The linter cannot know that, and it does not
pretend to.
nplusone had its own share of noise: writes through a relation
(interface.vdcs.set(...)) reported as reads, one field suggested in both
select_related and prefetch_related, and loops in test files. After those
fixes I drew a random sample of the remaining high-confidence findings
across the six projects, and all six were real N+1s. One example is
installation.organization read inside a loop over installations, which is
fixed with select_related("organization").
A static analyzer is only as good as its precision on code it has never seen. Synthetic test fixtures confirmed that the rules did what I wrote. They could not tell me whether what I wrote was true of Django. Big open-source codebases can, and running against them is now part of how the rules are tested.
All of the above shipped in py-1.14.0. Try it on your own history:
uvx orm-lens migration-risk # migration findings, critical by default
uvx orm-lens nplusone # N+1 loops, with the select_related to add
It reads source only: no database, no settings module, no django.setup().
It is free and MIT. If it is wrong about your project, open an
issue. That is how
this whole list came about.