SQLAlchemy Models, Ptah Migration Review
Turn an Alembic development schema into a reviewed Ptah migration, then catch a missing database default that successful SQLAlchemy inserts can hide.
Ptah helps you plan, review, and apply database migrations. Try in your browser
An ORM insert can succeed while another writer fails against the same table. One cause is a default that exists in Python but is missing from PostgreSQL. The application supplies a value, so its usual write path never notices.
We’ll add reserved inventory to a SQLAlchemy application, use Ptah to deploy the database change, and deliberately remove its server default afterward. That lets us test whether a live-schema check catches something the ORM’s successful insert does not.
This run used SQLAlchemy 2.0.54, Alembic 1.20.0, psycopg 3.3.6, Python 3.14.5, Ptah 0.10.0, and PostgreSQL 18.6. The supporting files contain the models, native Alembic setup, client programs, and recorded checks.
Put the default in the database model
Section titled “Put the default in the database model”The product table starts with an ID, SKU, and stock count. The application
has two products. We add reserved_units to the model:
reserved_units: Mapped[int] = mapped_column( Integer, default=0, server_default=text("0"))default=0 supplies a value through SQLAlchemy’s insert path.
server_default=text("0") describes a PostgreSQL default. The latter matters
to existing rows and writers that do not use this model.
In this workflow, Alembic materializes the model in a disposable development
database. Ptah handles migration execution on the application database.
Alembic’s env.py reads Base.metadata; its version table lives in
alembic_history, while application objects live in public.
alembic revision --autogenerate -m "reserved units"alembic upgrade headReview Alembic’s generated revision before applying it to development. Our
revision added an integer column with nullable=False and a server default
of zero.
Inspect the development schema with Ptah
Section titled “Inspect the development schema with Ptah”The handoff uses PostgreSQL’s stored schema, read directly by Ptah:
ptah schema inspect --db-url "$MODEL_DB_URL" \ --schemas public --format hcl > schema.hclThe result contains the product table and its server defaults. Restricting
inspection to public leaves Alembic’s history outside the desired application
schema. The output file is the state we’ll review and deploy.
This route uses the model’s existing migration tool and Ptah’s native inspection command. It does not need a Python program that prints DDL.
Generate and verify the deployment migration
Section titled “Generate and verify the deployment migration”DB_URL points to the application database. SHADOW_URL names another
disposable database that Ptah can reset while checking the migration history.
ptah migrations generate --db-url "$DB_URL" \ --schema-file schema.hcl --schemas public \ --migrations-dir migrations --name reserved_units \ --shadow-db "$SHADOW_URL"Ptah generated this addition:
ALTER TABLE "public"."products" ADD COLUMN "reserved_units" integer NOT NULL DEFAULT 0;The new pair passed shadow apply/rollback/reapply verification. Review the files, update the checksum, and lint the new version before deployment:
ptah migrations hash --dir migrationsptah migrations lint --dir migrations --dialect postgres --latest 1 --fail-on errorptah migrations up --db-url "$DB_URL" \ --migrations-dir migrations --verify-sumptah schema compare --db-url "$DB_URL" \ --schema-file schema.hcl --schemas public --exit-codeLint had no findings. The existing database applied one pending migration, and the comparison passed. An empty database replayed both the initial table and the new column migration, then matched the same desired schema.
Check writes through the ORM and plain SQL
Section titled “Check writes through the ORM and plain SQL”The SQLAlchemy application reserved two units of KIT-42 and inserted a
new product without explicitly setting reserved_units. Its results were:
| Product | Stock | Reserved | Available |
|---|---|---|---|
| KIT-42 | 10 | 2 | 8 |
| CABLE-7 | 5 | 0 | 5 |
| ADAPTER-2 | 3 | 0 | 3 |
A plain SQL insert that also omitted reserved_units succeeded and returned
zero. That second write proves the database can supply the value independently
of SQLAlchemy.
Remove the server default and see which check notices
Section titled “Remove the server default and see which check notices”We deliberately dropped the default in the disposable application database.
The column remained NOT NULL, and its existing values remained intact.
A new SQLAlchemy insert still succeeded: Python supplied zero. The equivalent plain SQL insert failed:
ERROR: null value in column "reserved_units" of relation "products" violates not-null constraintPtah also detected the missing default. Repeating the same schema comparison
returned exit code 1 and proposed restoring DEFAULT 0 on reserved_units.
Successful ORM writes had masked a real difference in the database’s contract.
We previewed the repair through ptah schema apply --dry-run. The plan was a
single statement that restores DEFAULT 0 and leaves the column’s type and
NOT NULL alone. We applied it to the lab database. Another comparison
passed, and a plain SQL insert again received zero from PostgreSQL. The full
repair plan and commands are in the supporting files.
Keep deployment checks independent of the ORM write path
Section titled “Keep deployment checks independent of the ORM write path”Alembic can already generate and execute migrations. Here it remains the development tool, while Ptah provides the deployment migration, linting, shadow verification, and live-schema comparison used across services. Only Ptah runs schema changes on the application target in this workflow.
Commit the reviewed desired schema and Ptah migrations with the model change. Test the application through SQLAlchemy, and compare the deployed schema too. An ORM default can make a write succeed; a schema check tells us whether the database still provides the behavior promised to every writer.
Put Ptah to work
Example files
- alembic.ini
- alembic/env.py
- alembic/script.py.mako
- alembic/versions/a0fa672f4f9b_reserved_units.py
- alembic/versions/eead18c12180_products.py
- application.py
- expected/change.sql
- expected/model.py
- expected/raw-failure.txt
- measured/application.txt
- measured/commands.json
- measured/compare-drift.txt
- measured/compare-fresh.txt
- measured/compare-live.txt
- measured/compare-restored.txt
- measured/drift-inject.txt
- measured/generate-v1.txt
- measured/generate-v2.txt
- measured/hash-v1.txt
- measured/hash-v2.txt
- measured/history-schema.txt
- measured/inspect-v1.txt
- measured/inspect-v2.txt
- measured/lint-v2.txt
- measured/model-v1.txt
- measured/model-v2.txt
- measured/orm-after-drift.txt
- measured/raw-insert-after-drift.txt
- measured/raw-insert-restored.txt
- measured/raw-insert.txt
- measured/repair-apply.txt
- measured/repair-preview.txt
- measured/revision-v1.txt
- measured/revision-v2.txt
- measured/seed-client.txt
- measured/up-fresh.txt
- measured/up-live.txt
- measured/up-v1.txt
- measured/versions.txt
- migrations/1790574391_products.down.sql
- migrations/1790574391_products.up.sql
- migrations/1790574408_reserved_units.down.sql
- migrations/1790574408_reserved_units.up.sql
- migrations/ptah.sum
- models_v1.py
- models_v2.py
- models.py
- orm_insert.py
- README.md
- requirements.txt
- schema-v1.hcl
- schema.hcl
- seed.py
- verified.json