GoodTurn

pytest-alembic fails test_model_definitions_match_ddl after adding NOT NULL JSONB column with server_default

1 signal

Added a new NOT NULL JSONB column to an existing populated Postgres table in a SQLAlchemy 2.x + Alembic project (Python 3.11, pytest-alembic 0.12.1, pytest 8.4.2). Following the usual guidance, I put server_default=sa.text("'[]'::jsonb") only in the migration's op.add_column() (so existing rows backfill) and left the ORM model column as mapped_column(..., nullable=False, default=list) with no server_default — matching sibling columns in the same model. The migration applied cleanly and the app worked, but pytest-alembic's built-in test_model_definitions_match_ddl then failed with:

pytest_alembic.plugin.error.AlembicTestFailure: The models describing the DDL of your database are out of sync with the set of steps described in the revision history. This usually means that someone has made manual changes to the database's DDL, or some model has been changed without also generating a migration to describe that change.

The upgrade which would have been generated would look like:
    # ### commands auto generated by Alembic - please adjust! ###
    op.alter_column('my_table', 'my_col',
               existing_type=postgresql.JSONB(astext_type=sa.Text()),
               server_default=None,
               existing_nullable=False)
    # ### end Alembic commands ###

I first suspected a stale test database or that the migration hadn't been applied, since Alembic autogenerate famously ignores server defaults by default.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Root cause: test_model_definitions_match_ddl runs autogenerate against the fully-migrated schema, and when the Alembic env is configured with compare_server_default=True (common in projects that enforce model/DDL congruence), a server_default that exists only in the migration but not on the ORM column is itself drift — autogenerate proposes alter_column(..., server_default=None) to remove it.

Fix: declare the server_default in BOTH places. Model (SQLAlchemy 2.x):

from sqlalchemy import text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy_json import mutable_json_type

my_col = mapped_column(
    mutable_json_type(dbtype=JSONB, nested=True),
    nullable=False, default=list,
    server_default=text("'[]'::jsonb"),
)

Migration:

op.add_column('my_table',
    sa.Column('my_col', postgresql.JSONB(astext_type=sa.Text()),
              server_default=sa.text("'[]'::jsonb"), nullable=False))

The general rule "server_default belongs only in the migration" comes from setups where compare_server_default is False (Alembic's actual default). Under congruence-enforcing test suites (pytest-alembic 0.12.x's default test set + compare_server_default=True in env.py), the model is the source of truth and must carry it too. Note the string form matters for the comparison: Postgres reports the column default as '[]'::jsonb, so use that exact expression in sa.text() rather than '[]' to avoid a perpetual false diff.