Alembic Sentinel
Alembic Sentinel Catch risky PostgreSQL migrations before they reach production. Alembic Sentinel is a small, offline linter for Alembic revision files. It flags migration operations that commonly block writes, rewrite large tables, fail inside Alembic's transaction, or break older application instances during a rolling deployment. It parses Python source without importing or running it. No database connection, Alembic installation, SQLAlchemy installation, credentials, configuration file, or network access is required. That makes it quick to add to a local review loop or CI gate. Alembic Sentinel is a focused tripwire, not a promise that a migration is safe. It finds the high-value hazards described in the rule table below and leaves the final deployment decision with your team. Install Alembic Sentinel requires Python 3.9 or newer and has no runtime dependencies outside the standard library. Copy main.py and sentinel.py into the same directory in your repository, for example: tools/ alembic-sentinel/ main.py sentinel.py Confirm that it runs: python tools/alembic-sentinel/main.py --version No package installation is necessary. Usage Pass one or more Alembic migration files or directories: python tools/alembic-sentinel/main.py alembic/versions python tools/alembic-sentinel/main.py alembic/versions/20260801_add_name.py python tools/alembic-sentinel/main.py migrations/core migrations/billing Directories are searched recursively for .py files. Duplicate inputs are scanned once, results are sorted deterministically, and __pycache__ directories are ignored. At least one path is required. usage: alembic-sentinel [-h] [--format {text,json}] [--fail-on {warning,error,never}] [--version] PATH [PATH ...] Options: --format text prints review-friendly findings and is the default. --format json prints a machine-readable object containing files_scanned, severity counts, and a findings array. --fail-on warning exits nonzero for warnings or errors and is the default. --fail-on error reports warnings but exits nonzero only for errors. --fail-on never reports every finding without failing the command. --version prints the installed version. Worked example This revision adds a required column and builds an ordinary index in one deployment: from alembic import op import sqlalchemy as sa def upgrade(): op.add_column( "users", sa.Column("display_name", sa.Text(), nullable=False), ) op.create_index("ix_users_display_name", "users", ["display_name"]) Running Sentinel against it produces: $ python tools/alembic-sentinel/main.py alembic/versions/20260801_user_name.py alembic/versions/20260801_user_name.py:5:5: ERROR AS001: Adding a NOT NULL column without a server default can fail when rows already exist; add it nullable, backfill it, then enforce NOT NULL. alembic/versions/20260801_user_name.py:9:5: ERROR AS004: Creating an index without postgresql_concurrently=True blocks table writes until the build finishes. Found 2 hazards (2 errors, 0 warnings) in 1 file. The default policy exits with status 1, so CI stops before deployment. A safer expand-stage revision adds the column as nullable and builds the index concurrently outside Alembic's migration transaction: from alembic import op import sqlalchemy as sa def upgrade(): op.add_column( "users", sa.Column("display_name", sa.Text(), nullable=True), ) with op.get_context().autocommit_block(): op.create_index( "ix_users_display_name", "users", ["display_name"], postgresql_concurrently=True, ) Deploy application code that tolerates and populates the new column, then backfill old rows in bounded batches outside the schema migration. After that backfill, a later revision can add and validate the invariant without validating old rows while holding the initial add-constraint lock: from alembic import op def upgrade(): op.create_check_constraint( "ck_users_display_name_present", "users", "display_name IS NOT NULL", postgresql_not_valid=True, ) op.execute( "ALTER TABLE users " "VALIDATE CONSTRAINT ck_users_display_name_present" ) Enforcing the column's NOT NULL attribute and removing compatibility scaffolding should be a separately reviewed contract step after all deployed application versions can tolerate it. Rules Sentinel checks Alembic operation calls and equivalent SQL when the string passed to execute() can be resolved statically. | Rule | Severity | What is flagged | Safer direction | | --- | --- | --- | --- | | AS001 | Error | add_column() with nullable=False and no usable server default (including sa.text("NULL")); literal ALTER TABLE ... ADD COLUMN ... NOT NULL without a default | Add the column nullable, backfill it, then enforce the invariant in a later step. | | AS002 | Error | alter_column(..., nullable=False) and literal ALTER TABLE ... SET NOT NULL | Validate a NOT VALID check constraint first, then review the final contract step. | | AS003 | Error | alter_column() with a non-None type_, and literal ALTER TABLE ... TYPE | Use an expand/backfill/contract rollout when the conversion can rewrite the table. | | AS004 | Error | create_index() on an existing table without literal postgresql_concurrently=True, and non-concurrent literal CREATE INDEX | Build the index concurrently. An index on a table created earlier in the same upgrade() is exempt because it has no production writers yet. | | AS005 | Error | A concurrent create_index() or drop_index(), including literal SQL, outside with op.get_context().autocommit_block(): | Put the concurrent operation in an explicit Alembic autocommit block. | | AS006 | Warning | drop_index() without literal postgresql_concurrently=True, and non-concurrent literal DROP INDEX | Use a concurrent drop where PostgreSQL supports it. | | AS007 | Error | Creating unique, primary-key, or exclusion constraints, including equivalent literal ALTER TABLE statements (a literal UNIQUE USING INDEX attachment is exempt) | Stage and review the constraint; for unique constraints, build a compatible unique index concurrently before attaching it. | | AS008 | Error | Creating foreign-key or check constraints without literal postgresql_not_valid=True, including ForeignKey() inside an added column and equivalent literal SQL without NOT VALID | Add the constraint as NOT VALID, then validate it separately. | | AS009 | Error | Dropping a table or column, renaming a table or column, and equivalent literal SQL | Use an expand/contract rollout and defer removal until old application instances are gone. | | AS010 | Error | A column server default using a recognized volatile function: clock_timestamp(), random(), nextval(), gen_random_uuid(), or uuid_generate_v4() | Add without the volatile default and backfill in batches. | | AS011 | Warning | bulk_insert() and literal unbounded UPDATE, DELETE FROM, TRUNCATE, MERGE, or INSERT ... SELECT passed to execute() | Move data work to a bounded, resumable process outside the schema-migration transaction. | | AS012 | Warning | Dynamic SQL passed through execute(), or an opaque dollar-quoted DO block, that cannot be inspected safely | Review the SQL manually or make it a directly inspectable static statement. | | AS013 | Error | batch_alter_table(..., recreate="always") | Avoid forced copy-and-recreate batch mode for an online PostgreSQL migration. | A single operation can produce more than one finding. For example, changing both nullability and type in one alter_column() call produces AS002 and AS003. Output and exit codes Text findings use this stable shape: PATH:LINE:COLUMN: SEVERITY RULE: MESSAGE Finding paths are relative to the current directory when possible. Line and column numbers are one-based. A clean text scan ends with: OK: scanned 12 migration files; no hazards found. JSON output has this shape: { "counts": { "error": 1, "warning": 0 }, "files_scanned": 1, "findings": [ { "code": "AS009", "column": 5, "line": 8, "message": "This destructive or renaming operation can break older application instances during a rolling deploy; defer it to a contract migration.", "path": "alembic/versions/20260802_contract.py", "severity": "error" } ] } | Exit code | Meaning | | --- | --- | | 0 | No finding met the selected --fail-on threshold. Findings may still have been printed with --fail-on error or --fail-on never. | | 1 | At least one finding met the selected threshold. | | 2 | Invalid command usage or input: for example, a missing path, non-Python file, empty directory, unreadable file, or Python syntax error. | Scan results go to standard output. Input errors go to standard error. CI example Keep the default policy to reject both warnings and errors: - name: Check Alembic migrations run: python tools/alembic-sentinel/main.py --fail-on warning alembic/versions For gradual adoption, --fail-on error keeps warning-level review notes visible while blocking error-level hazards. --format json is suitable for a CI annotation or reporting step. Deliberate limitations Alembic Sentinel stays small and predictable by being a static, PostgreSQL-specific checker: It inspects top-level upgrade() and upgrade_*() function bodies. It deliberately ignores downgrades and does not follow calls into helper functions or nested function definitions. It recognizes normal Alembic op imports, renamed imports, simple module-level aliases, batch_alter_table() aliases, direct op.get_bind().execute() calls, and simple local bind aliases. Arbitrary wrappers and runtime aliasing are outside its scope. It does not execute Python, resolve runtime values, connect to PostgreSQL, inspect table contents or size, or know the database server version. Safety switches such as postgresql_concurrently=True and postgresql_not_valid=True must be literal booleans. A variable or computed value is not accepted as proof of safety. SQL analysis is intentionally narrow. A small quote- and comment-aware lexer inspects static strings, constant concatenation, expression-free f-strings, and common text() or dedent() wrappers without treating keywords inside values as operations; dynamic SQL and procedural DO bodies receive AS012 for manual review. It recognizes an explicit lexical with op.get_context().autocommit_block(): around concurrent index operations. It does not infer transaction behavior from env.py or custom migration infrastructure. Recursive directory scans include every .py file except files under __pycache__. Point the command at migration directories rather than an entire repository if unrelated Python files should be excluded. There is no configuration language, suppression syntax, baseline, migration runner, or automatic rewrite. --fail-on controls policy without hiding findings. A clean scan is not a zero-downtime certification. Lock duration and rollout safety still depend on PostgreSQL behavior, data volume, traffic, application compatibility, and operational sequencing.
Get it → aegisystems.gumroad.com