Skip to main content

migcheck logo migcheck logo

CI License GitHub Action Release PyPI Python 3.10+

migcheck checks that a release's database migrations don't break the release running in production.

During a rolling deploy, the old release keeps serving traffic after the new release's migrations run. After a rollback, it runs against the new schema indefinitely. A migration that passes review and passes the new release's tests can still take down the old release. Migration linters check the SQL. migcheck runs the old release's queries against the new schema and tells you which ones fail.

For example, this is migcheck's output for the demo, where release v2-rename renames a column that v1 uses:

FAIL: 4 statements from v1 break after v2-rename's migrations.

  [42703] column "name" of relation "users" does not exist
      2 statements in 1 transactions. Examples:
      INSERT INTO users (name, email) VALUES ('Ada O''Brien', 'ada@example.com') RETURNING id
      UPDATE users SET name = 'Ada Lovelace' WHERE id = '1'

  [42703] column "name" does not exist
      2 statements in 1 transactions. Examples:
      SELECT id, name, email FROM users WHERE id = '1'

v1 fails during a rolling deploy of v2-rename, and after a rollback to v1.

migcheck supports Postgres and MySQL. It's an early release.

What it catches

migcheck catches any change that makes the old release's queries fail, including the following:

  • Renamed or dropped columns and tables.
  • New NOT NULL columns without a database default. The old release's inserts don't set them.
  • New constraints that the old release's writes violate.
  • Changed column types that the old release's values don't fit.

The NOT NULL case is easy to miss in Django. When a migration adds a field with a default, Django fills existing rows with it and then drops the default from the database. The migration looks safe, but the old release's inserts fail.

Run migcheck on the database you deploy to. The same query can pass on one database and fail on another.

migcheck has been tested against real upgrades of Django, Rails, Go, Node, and Java apps. For the results, see EVIDENCE.md.

Install

migcheck needs Docker, git, and Python 3.10 or later. It has no Python dependencies and doesn't need a database client on the host.

pipx install migcheck

Use it

Run migcheck from your app's repository:

migcheck --base <deployed-ref> --head <new-ref> \
  --migrate "<command that applies migrations>" \
  --workload "<command that runs your test suite>"
  • --base is the release running in production, such as its tag.
  • --head is the release you're about to deploy.
  • --migrate and --workload are shell commands. migcheck runs them in a checkout of each ref, with DATABASE_URL pointing at a throwaway database.
  • --db is the database your app uses: postgres, the default, or mysql.

Your app must read its database connection from DATABASE_URL in the test environment. If it reads other variables, wrap the commands in a script that sets them.

DATABASE_URL starts with postgresql:// or mysql://. If your framework expects another scheme, such as mysql2:// for Rails, rewrite it in the command:

migcheck --db mysql --base v5.2.0 --head main \
  --migrate 'DATABASE_URL=mysql2${DATABASE_URL#mysql} bin/rails db:migrate' \
  --workload 'DATABASE_URL=mysql2${DATABASE_URL#mysql} bin/rails test'

For example, for a Django app:

migcheck --base v1.4.0 --head main \
  --migrate "python manage.py migrate" \
  --workload "pytest"

For Rails, SQLAlchemy, Go, Node, and apps that migrate on startup, and for apps that don't read DATABASE_URL, see RECIPES.md.

migcheck exits with one of the following codes:

Code Meaning
0 Every query the old release made still works.
1 At least one query breaks.
2 migcheck couldn't compare, for example because every query failed on the old schema too.

A failing workload, such as a test suite with failing tests, still issues queries, so migcheck checks the queries it ran. If none of them break, migcheck reports a pass with a warning, because a workload that stopped early can skip the queries that break. To make a failing workload exit with code 2 instead, use --require-workload-success.

Apps that deploy in two steps

Some apps split their migrations into two sets. The first set runs before the new release rolls out, and the second runs after every server of the old release has stopped. Mastodon and GitLab call the second set post-deployment migrations.

The old release never runs against the second set, so --migrate must apply only the first. Otherwise migcheck reports breaks that the app's deploy process already avoids. For example, Mastodon skips its post-deployment migrations when SKIP_POST_DEPLOYMENT_MIGRATIONS is set.

Options

Flag Description
--base Ref of the release running in production. Required.
--head Ref of the release being deployed. Required.
--migrate Shell command that applies migrations to $DATABASE_URL. Required.
--workload Shell command that exercises the app against $DATABASE_URL, such as its test suite. Required.
--db Database the app uses: postgres or mysql. Default: postgres
--repo Git repository to check. Default: .
--image Database Docker image. Use the major version you run in production. Default: postgres:17-alpine or mysql:8.4
--head-name Name to show for --head in the result, such as this pull request.
--markdown Also write the result as Markdown to this path, for a pull request comment.
--require-workload-success Exit with code 2 if the workload command fails, instead of checking the queries it ran.

See it in action

The migcheck-example repository runs the action on its pull requests. Each pull request adds a migration, and migcheck comments with the result:

Run it in GitHub Actions

The migcheck action runs the check on each pull request and posts the result as a comment. It updates the same comment when the pull request changes.

name: migcheck
on:
  pull_request:
    paths: ["migrations/**"]

permissions:
  contents: read
  pull-requests: write

jobs:
  migcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
      # Install your app's dependencies here.
      - uses: Genetic-Pottery/migcheck@v0
        with:
          migrate: python manage.py migrate
          workload: pytest

The checkout step must use fetch-depth: 0, so that migcheck can read the base release and its tags.

By default, the base release is the newest tag reachable from the pull request's base branch. If you tag releases differently, set base to the ref running in production.

The action takes the following inputs:

Input Description
migrate Shell command that applies migrations to $DATABASE_URL. Required.
workload Shell command that exercises the app against $DATABASE_URL. Required.
base Ref of the release running in production. Default: the newest tag reachable from the base branch.
head Ref of the release being deployed. Default: HEAD
db postgres or mysql. Default: postgres
image Database Docker image. Default: postgres:17-alpine or mysql:8.4
require-workload-success Whether a failing workload command is an error. Default: false
working-directory Directory of the repository to check. Default: .
comment Whether to post a pull request comment. Default: true
comment-key Keeps comments separate when a workflow runs migcheck more than once. Default: default

The action fails the job when a query breaks or migcheck can't compare, and sets the result output to pass, fail, or error. It also writes the result to the job summary.

Pull requests from forks get a read-only token, so the action can't comment on them. The result still appears in the job summary.

Try the demo

The demo builds a small repository with one base release and three candidate releases. Two of the candidates break the base release.

python3 -m venv /tmp/mc-venv
/tmp/mc-venv/bin/pip install "psycopg[binary]"
examples/make-demo.sh /tmp/mc-demo

migcheck --repo /tmp/mc-demo --base v1 --head v2-rename \
  --migrate "/tmp/mc-venv/bin/python migrate.py" \
  --workload "/tmp/mc-venv/bin/python test_app.py"
Tag Migration Result
v2-expand Adds full_name next to name PASS
v2-rename Renames name to full_name FAIL
v2-notnull Adds plan text NOT NULL with no default FAIL

How it works

  1. migcheck checks out the base and head refs into temporary git worktrees.
  2. It starts a throwaway database container with statement logging on.
  3. It applies the base release's migrations, runs the base release's workload, and records every query the workload makes.
  4. It makes two copies of the base schema and applies the head release's migrations to one of them.
  5. It replays the recorded queries against both copies, with bind parameters inlined.
  6. It reports each query that succeeds on the old schema and fails on the new one.

Replaying against both copies filters out queries that fail for reasons unrelated to the migration, such as missing seed data. The report counts these skipped queries and lists their most common errors, so you can tell whether replay missed data that later queries needed. If 10% or more of the queries are skipped, the report includes a warning.

migcheck also checks for two signs of a wrong setup. If the base release's migrate command creates no tables in the database at DATABASE_URL, migcheck exits with code 2. If the head release's migrations don't change the schema, the report includes a note.

Replay follows the workload's transactions. If the workload rolled back a transaction, as test runners do after each test, replay rolls back its effects too. The report lists the first failure in each transaction as a root cause and groups later integrity errors in that transaction separately.

migcheck also accounts for how test runners use the database:

  • It records queries from every database the workload touches, because test runners such as Django's create their own.
  • It drops DDL and transaction-control statements, which come from the test runner rather than the app.
  • It replays TRUNCATE by emptying whichever listed tables exist, so cleanup between tests doesn't depend on the schema.
  • On Postgres, it records the primary key of each row the workload inserts and gives replayed inserts the same keys, so later statements that reference those rows find them. If a migration already inserted a row with that key, the replayed insert gets a new key instead, as it would in production. On MySQL, replay starts from the same auto-increment counters as the workload, so inserts get the same keys when the workload runs one test at a time.

Everything runs locally. The database container listens only on 127.0.0.1, and migcheck removes the container and worktrees when it finishes.

Limitations

  • migcheck checks only the queries the workload makes. If your tests never touch a table, migcheck can't see it break.
  • migcheck applies migrations to an empty database. A migration that fails or locks for a long time on a large production table can pass here.
  • Replay runs one statement at a time. Failures that depend on concurrency don't show up.
  • migcheck checks for errors, not wrong results. A query that returns different rows on the new schema passes.
  • On Postgres, replay ignores SAVEPOINT and ROLLBACK TO SAVEPOINT.
  • On MySQL, a workload that runs tests in parallel can leave replayed rows with different keys than the workload saw. Statements that reference those keys then fail on both schemas, and migcheck skips them.
  • migcheck supports MySQL 8.0 and later. It doesn't support MariaDB, whose query log has a different format.
  • Postgres words some errors differently for reads and writes, so one missing column can appear as two root causes.

Run the tests

To run the tests, run the following from the repository root:

python3 -m unittest

The replay tests start Postgres and MySQL containers, so they need Docker. Without Docker, they're skipped.

The parser tests read logs that were recorded from real servers. To record them again, for example after you change the workload in tests/capture_fixtures.py, run python3 -m tests.capture_fixtures.

Release a version

  1. Set version in pyproject.toml to the new version, such as 0.2.0.

  2. Add a ## v0.2.0 - YYYY-MM-DD section to CHANGELOG.md.

  3. Commit, then tag and push:

    git tag -a v0.2.0 -m v0.2.0
    git push origin main v0.2.0
    

The release workflow checks that the tag matches pyproject.toml and that CHANGELOG.md has a section for it, then runs the tests and builds the package. If they pass, it publishes the package to PyPI, creates the GitHub release with that section as its notes, and moves the major version tag, such as v0, to the release. If a check fails before the PyPI upload, nothing is released. To retry, delete the tag, fix the problem, and push the tag again. PyPI never accepts the same version twice, so after the upload, a fix needs a new version.

License

Apache 2.0. See LICENSE.

Metadata

Release files for migcheck 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for migcheck 0.2.0
File Size Uploaded Provenance
migcheck-0.2.0.tar.gz 41.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for migcheck 0.2.0
File Interpreter ABI Platform
migcheck-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 70.2 kB

Release files / migcheck-0.2.0.tar.gz

Download URL migcheck-0.2.0.tar.gz
Size 41.8 kB
Tags Source
SHA-256 checksum
How to use checksums
0f2eec7b1ad67551938d2f5a8d8a2eda47ca95da6c6f58698d4767745eb1f944
BLAKE2b-256 checksum
How to use checksums
4cf9540473ce6ff0c95a16e2019f14b6efbf9b61800da7d62689575eb6928d13
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Release files / migcheck-0.2.0-py3-none-any.whl

Download URL migcheck-0.2.0-py3-none-any.whl
Size 28.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
90a4360300d06ef019d648a2680cb7c9b829dbbe431c6b92c1c1608097b74b5c
BLAKE2b-256 checksum
How to use checksums
678e4442543d8a677ef2f739ce2e9c11ab6b85413c08b857dfbf3f5941cedf6e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Security for migcheck 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for migcheck-0.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page