Here's a failure I've now watched happen on three different teams.
Two people branch off main. Alice adds 0007_add_index.up.sql. Bob adds
0007_add_orders_fk.up.sql. Both PRs pass CI in isolation — of course they do,
each migration is fine on its own. Both merge. Now main has two migrations
claiming version 0007, and depending on which migration tool you use, the
runner either picks one and silently skips the other, or aborts the entire
deploy with a version-conflict error nobody can reproduce locally.
This isn't an exotic bug. It's a known, recurring limitation
in essentially every sequence-numbered migration tool. The collision is invisible
right up until it runs in the one environment where you least want surprises.
Why nothing already caught it
The frustrating part: the fix is trivial. You just have to look at the
filenames and notice two of them start with the same number. So why wasn't this
already linted?
Because the existing linters are tied to a framework or need a live database:
-
django-migration-linter— Django only. -
migration-linton PyPI — Django / Alembic only. - Flyway's own validation — Flyway only, and it wants a DB connection.
If your team uses raw SQL with goose, dbmate, golang-migrate, or a
hand-rolled migrations/ folder — which is a lot of teams — there is nothing
that just reads the directory and tells you it's sane. So everyone re-writes the
same 30-line "check for duplicate numbers" shell script, badly, once.
I got tired of re-writing it. So I made migrolint.
What it checks
migrolint reads only filenames. No database, no framework, no config. Four rules:
| Rule | Severity | Meaning |
|---|---|---|
DUPE_NUM |
error | two migrations share a version number (the collision above) |
MISSING_DOWN |
warning | an up migration with no matching down (only flagged if you use up/down splits) |
SEQ_GAP |
warning | a hole in an integer sequence — usually a deleted or un-merged migration |
BAD_FORMAT |
warning | a file whose name no known convention recognizes |
$ migrolint db/migrations
migrolint db/migrations (14 files, 12 migrations)
✗ DUPE_NUM version 0007 used by 2 migrations:
0007_add_index.up.sql
0007_add_orders_fk.up.sql
⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
⚠ SEQ_GAP missing version(s): 8
1 error, 2 warnings.
Exit code is 1 on errors, 0 when clean — so it drops straight into a
pre-commit hook or a CI step:
- run: npx migrolint --strict # --strict makes warnings fail too
It speaks your naming convention
The whole trick is parsing version numbers out of filenames across the
conventions people actually use, so you don't have to configure anything:
| Convention | Example |
|---|---|
| Flyway |
V1__init.sql, U1__undo.sql, R__refresh.sql
|
| golang-migrate / dbmate |
0001_create_users.up.sql + .down.sql
|
| goose / Rails |
20230101120000_create_users.sql (timestamp) |
| minimalist |
1_init.sql, 2-add-index.sql
|
Timestamp-style versions are recognized but exempt from SEQ_GAP (they're never
meant to be contiguous), and well-known non-migration files like schema.rb and
structure.sql are skipped automatically.
Install
npx migrolint # Node
pip install migrolint # Python — same checks, same flags
Zero dependencies on both sides — pure stdlib. It auto-detects migrations/,
db/migrations/, supabase/migrations/, and a few other common paths, or you
point it at a directory.
A few design choices I'd defend
- Filenames only, on purpose. No DB connection means it runs in a fraction of a second, in any repo, with no credentials — exactly what you want in a pre-commit hook. The DB-connected checks (does this migration actually apply?) are a different tool's job.
- Deterministic output. It sorts entries before analyzing, so the verdict doesn't depend on filesystem ordering and the Node and Python ports produce byte-identical results. A mixed-language team gets one answer.
-
Conservative warnings.
MISSING_DOWNonly fires if your project actually uses.up/.downsplits — forward-only setups don't get nagged. The goal is zero false positives, because a linter that cries wolf gets--no-verify'd.
It's MIT, both repos are public:
migrolint (Node) ·
migrolint-py (Python).
I'm curious where the gaps are: what migration-folder convention does your team
use, and what other filename-level mistakes have bitten you that a tool like
this should catch? Down-migrations that don't actually reverse the up? Timestamp
collisions to the second? Tell me and I'll look at adding rules for them.
Top comments (0)