Design notes
migraguard is a pipeline of small, separately testable stages (lint.py):
discover(root) -> Project AST parse of every */migrations/[0-9]*.py (+ models.py class names)
scope (optional git base) which migrations / apps are "in scope"
analyze(project, scope) operation rules + graph rules -> raw findings
run_sql_mode(...) (optional) Postgres SQL via schema editor -> SQL attached / MG2xx findings
apply_policy(...) ignores, severity overrides, table-size escalation
report rich | json | github | sarif
1. Discovery without Django
A migration is parsed, never imported. parse_migration finds the Migration class and reads four class attributes with ast: operations (a literal list of calls), dependencies and replaces (lists of 2-tuples), and atomic. Each operation (and each field) is wrapped in CallInfo, which resolves a keyword or positional argument and evaluates it with ast.literal_eval. When a value is not a literal, lit() returns the UNKNOWN sentinel and rules skip the case rather than guess. That is why migraguard produces no false positive for null=SOME_FLAG.
The app label is the directory holding migrations/. Directories such as .venv, node_modules, site-packages and .git are not walked.
2. The graph
Project offers three views, all computed from dependencies and replaces:
- effective nodes: migrations that are not replaced by a squashed migration. A dependency on a replaced name is remapped to the squash, exactly as Django's loader does when nothing is applied yet.
- leaves of an app: effective nodes that no other node of the same app depends on. More than one leaf is
MG100. - order: a depth-first topological order, ties broken by name.
Worked example. 0001_a, 0002_b, a squash 0001_squashed_0002_b with replaces = [0001_a, 0002_b], and 0003_c depending on ("app", "0002_b"):
effective nodes 0001_squashed_0002_b, 0003_c (0001_a, 0002_b are replaced)
0003_c depends on 0002_b -> remapped to 0001_squashed_0002_b
leaves [0003_c] no conflict
__first__ resolves to the app's root migration, __latest__ to its leaf. swappable_dependency(...) and dependencies on apps that are not in the scanned tree are ignored. A dependency on a missing migration of a scanned app is MG103. Apps with an unparseable migration (MG104) are excluded from conflict detection because their dependencies are unknown.
MG101 compares the concrete model classes found in models.py / models/*.py (classes deriving from Model or from another model in the file, minus abstract = True) with the models that exist after replaying the app's migrations.
3. Replaying state for AlterField
AlterField cannot be judged from its own text: models.CharField(max_length=100) is dangerous only if it was 200 before. SchemaState replays CreateModel, AddField, AlterField, RemoveField, RenameField, RenameModel, DeleteModel, AlterModelTable, AlterUniqueTogether and the state_operations of SeparateDatabaseAndState in dependency order, keeping the last CallInfo per field plus table names (<app>_<model> unless db_table is set) and unique_together.
All migrations of an app are replayed, but rules only run for migrations in scope. In git base mode that means old migrations feed the state and only new ones produce findings. Rules run before an operation is applied to the state, so they see the "old" field.
_alter_reasons compares old and new:
| change | verdict |
|---|---|
null=True -> NOT NULL |
scan under ACCESS EXCLUSIVE |
CharField/SlugField/EmailField/URLField among themselves |
same column type |
varchar family -> TextField |
no rewrite, safe |
any other class change (IntegerField -> BigIntegerField) |
table rewrite |
max_length decreased |
scan, may fail on data |
DecimalField max_digits/decimal_places changed |
rewrite |
anything else (default, verbose_name, longer max_length) |
no database work |
Example from the sample project: catalog.0001 creates Product.name = CharField(max_length=200); 0005 has AlterField(name, CharField(max_length=100)), giving MG006 ... shrinks max_length 200 -> 100.
Operations on a model created in the same migration never fire (new_models): an empty table cannot be locked for long, so CreateModel followed by AddIndex is fine.
4. Rule semantics worth knowing
- MG001 vs MG002. No default at all fails on any non-empty table. A Python-side
defaultsucceeds but Django drops the database default right after the backfill, so servers still on the old release hitNOT NULLviolations on INSERT.db_default(Django 5.0+) removes both findings. A callable default (default=uuid.uuid4) is recognised as a bare name/attribute rather than a call. - MG007 / SlugField.
SlugFieldis indexed unlessdb_index=False, so adding one is an index build. Foreign keys are reported once asMG011, not again as an index. - MG009 is an error because the migration cannot succeed:
CREATE INDEX CONCURRENTLYis forbidden inside a transaction. - MG014 looks for
forloops in the function passed toRunPythonwhose iterable is anall/filter/exclude/order_bychain (following one level ofqs = ...; for x in qs)..iterator(),.values(),.values_list()and slicing mark it as batched. - MG017 fires when a
RunPython/RunSQLshares an atomic migration with schema operations;SeparateDatabaseAndStateand model-option operations do not count as schema work.
5. SQL mode
Django's Postgres backend needs a server for a few things: a version check, and compose_sql, which asks the server to render parameters. sqlmode.generate_sql removes both dependencies:
- build a
DatabaseWrapperwith dummy settings and register it asconnections["migraguard"]; - pre-seed its
pg_versionso feature flags do not connect; - replace
ops.compose_sqlwith a local renderer based onpsycopg.sql.quote; - for each migration:
loader.project_state(key, at_end=False)for the state before it, thenmigration.apply(state, schema_editor, collect_sql=True)withschema_editor(collect_sql=True, atomic=False).
Result for accounts.0002_customer_phone:
ALTER TABLE "accounts_customer" ADD COLUMN "phone" varchar(20) DEFAULT '' NOT NULL;
ALTER TABLE "accounts_customer" ALTER COLUMN "phone" DROP DEFAULT;
The DROP DEFAULT on the second line is the SQL-level proof of MG002.
scan_statements pattern-matches each statement (whitespace and case normalised; statements touching a table created earlier in the same migration are skipped). Every SQL rule has a family of AST rules (SQL_FAMILY): for example MG202 CREATE INDEX belongs with MG007, MG008, MG011. When an AST finding of that family exists for the same migration and table, the statement is attached to it (finding.sql) and no duplicate is reported. If the AST rules missed it, for instance a raw RunSQL('ALTER TABLE "t" ADD COLUMN "x" integer NOT NULL'), a new MG201 finding is emitted. Because the match happens before ignores are applied, ignoring MG007 also silences the corresponding MG202.
6. Policy: ignores, severities, table sizes
apply_policy runs last, in this order for each finding: drop if disabled (ignore, severity off, exclude-apps, ignore-migrations, inline comment); apply the severity override; then, if a sizes file was given and the rule is size-aware, raise to error when rows >= big-table-rows.
Inline comments apply when the comment line is between one line above the operation's first line and its last line, or when it appears before class Migration (file-wide).
Table sizes: sizes.csv keys are database table names (case-insensitive); the finding's table comes from state (db_table, renames) so a renamed model is looked up under its new table.
7. Output contracts
- JSON: stable keys (
rule,severity,message,file,line,column,app,migration,operation,table,rows,suggestion,sql), plus asummaryblock andsqlper migration. - GitHub:
::<level> file=,line=,endLine=,col=,title=::message, with%, newlines,:and,escaped as the runner expects. - SARIF 2.1.0: the full rule catalog in
tool.driver.rules(id, short/full description, safe pattern ashelp, default level) and one result per finding withruleIndex, physical location and the suggestion appended to the message.