There are two questions hiding inside every schema change, and most migration tooling pretends they are one: “is this migration file correct,” which is a question about text, and “does the live database actually match what the text says,” which is a question about reality, and the reason the second question exists at all is that someone, somewhere, always opens a console against production and fixes a column by hand at 11pm, and six weeks later your deploy pipeline is applying migrations to a database that is not the database your migrations were written against. Back in the MySQL days we answered the second question with a cron job that ran mysqldump --no-data on both sides and diffed the output in a loop, which worked about as well as it sounds, because the dump order was never stable and half the diffs were noise about AUTO_INCREMENT counters. The modern equivalent in MongrelDB Kit is two subcommands that know which question they answer, and the discipline is keeping them apart: schema validate runs in CI and never touches a database, diff runs at deploy and never touches your CI, and each one is fast and boring in exactly the way a gate should be.

The CI half: schema validate

The whole point of the CI gate is that it needs no database, no credentials, and no network, because a check that needs infrastructure is a check people will disable the first time the staging box is down. You point it at the schema JSON file that lives in your repo, and it answers the narrow question of whether the file is internally consistent:

mongreldb-kit schema validate schema/schema.json
# OK: schema/schema.json

The schema file itself is plain JSON, one entry per table, and every table and every column carries a stable numeric id alongside its name, which is the part that makes everything else in this post possible:

{
  "tables": [
    {
      "id": 1,
      "name": "users",
      "columns": [
        { "id": 1, "name": "id", "storage_type": "int64", "nullable": false, "primary_key": true },
        { "id": 2, "name": "email", "storage_type": "text", "nullable": false },
        { "id": 3, "name": "age", "storage_type": "int64", "nullable": true, "min": 0, "max": 150 }
      ]
    }
  ]
}

Validation runs in two passes, and the order is deliberate. The first pass checks the stable IDs, because those IDs are how the engine tells a rename apart from a drop-and-add, and if two tables share an ID or two columns in one table share an ID, every downstream tool will misread the diff, so the validator bails immediately and names the offending ID in the error, something like table "users": duplicate/reused column id 3 used by "age" and "score", which is the kind of message you can act on at 6pm on a Friday without opening a debugger. The second pass is the full structural validation, primary keys and index references and foreign key targets, the checks that say the schema describes a database the engine could actually build. Because the whole thing is parse-and-check with no I/O beyond reading one file, it runs in milliseconds and belongs in the same CI step as cargo fmt --check or tsc --noEmit, and the exit code does the gating, so a broken schema fails the PR before anyone wastes review time on it.

The deploy half: diff

CI proves the file is correct, and then reality gets a vote, because the database you are about to deploy against has been alive for months and has its own history. The deploy-time question is whether the stored catalog, the schema the engine actually persists inside the database, matches the schema file you are about to start serving from, and that is what diff answers:

mongreldb-kit diff schema/schema.json /var/lib/myapp/store.kitdb

The output is a line per difference, prefixed by the direction of the change, so + table events means the code schema has a table the database does not, - column users.age means the database has a column the code dropped, and ~ column users.age type: Int64 -> Text means both sides have the column and disagree about what it holds (the diff prints the engine’s enum names in CamelCase while the schema file spells them lowercase, same types, two conventions), with the same treatment for unique constraints, foreign keys, and secondary indexes, matched by name so you see which specific constraint drifted. When everything agrees you get exactly one line, no drift, which is the output you want your deploy script to see before it flips traffic. One honest caveat about the wiring: diff exits zero even when it finds drift, because its author treated it as a reporting tool rather than a gate, so your deploy step should check the output for the no drift string or fail on any +/-/~ line rather than trusting the exit code, and yes, an --exit-code flag would be a reasonable feature request, but the grep works today and it has the side benefit of printing the drift into your deploy log, which is where you will want it when the gate fires.

This is also where the stable IDs earn their keep a second time, because the diff can detect column-ID reuse between the stored catalog and the code schema, which is the failure mode where someone “renames” a column by deleting it and adding a new one with a recycled ID, and in a system without stable IDs that mistake is invisible until a query returns the wrong data, while here it shows up as a flagged diff before the deploy goes out.

Closing the loop: generate migration

Finding drift is only useful if there is a cheap path back to agreement, and the third subcommand is that path, because it reads the same two sides and emits the migration skeleton that would bring the database up to the code schema:

mongreldb-kit generate migration schema/schema.json --from /var/lib/myapp/store.kitdb

It opens the database, reads the stored catalog and the applied-migration log, computes the next version number as one past the highest applied version, and prints a migration JSON array with the additive operations the delta implies, create_table for tables the database lacks and add_column for columns it lacks, ready for you to review, rename from its default "generated" name, and commit. The skeleton is deliberately conservative, since generating drops automatically is how tools eat data, so destructive changes stay a human decision and the generator only writes the parts where guessing is safe. From there the ordinary migration commands take over, migrate plan or its alias migrate dry-run to see what would apply, migrate status to see what already has, and migrate apply to run the pending set, which is the same loop we covered when we wrote about content-addressed migration checksums, since the checksum mechanism is what keeps a reviewed migration from being quietly rewritten between CI and deploy.

When you need both halves, and when you do not

If you are one developer with an embedded database file that ships inside the application, the full two-gate setup is more ceremony than the problem warrants, and running migrations at startup with migrate apply is fine, because the database and the code travel together and drift has nowhere to hide. The split pays for itself the moment there is a second environment, or a second person, or a production database that outlives any single deploy, because that is when “the migration is correct” and “the database matches the migration” become different statements with different evidence, and the cheap time to learn they have diverged is in a CI log or a deploy gate, not in a support ticket. Twenty years of watching teams re-learn this says the tooling is never the expensive part; the expensive part is the 11pm console session nobody wrote down, and a diff that prints the truth in four lines is the cheapest insurance against it we know how to build.