GORM with a Reviewed Ptah Migration
Move schema changes out of a Go application's startup, adopt its existing GORM database with Ptah, and deploy a reviewed change before starting the new code.
Ptah helps you plan, review, and apply database migrations. Try in your browser
A Go application calls GORM’s AutoMigrate when it starts. That puts schema
changes on the application’s startup path. For the next release, we want to
review the SQL, deploy it through Ptah, and let the application run with
ordinary data access permissions.
The change is small: customer names can grow from 64 to 128 characters, and each customer gains a locale. The existing database already has two customer rows, so the handoff must preserve them.
This run used GORM 1.31.2, gorm.io/driver/postgres 1.6.3, Go 1.27.1, Ptah 0.10.0, and PostgreSQL 18.6. The supporting files include both application versions, generated and reviewed SQL, dependency pins, and measured results.
Capture the schema the application created
Section titled “Capture the schema the application created”We ran the original application, including its AutoMigrate call, and inserted
two customers. Ptah then inspected that database:
ptah schema inspect --db-url "$DB_URL" \ --schemas public --format sql > schema-v1.sqlThe snapshot contains the customer table, its defaults and constraints, and the unique email index. Review that state before adopting it as the baseline.
Here, schema.sql will become the deployment source of truth, with GORM
models mapped to it. This workflow makes schema changes explicit in SQL;
it does not automatically translate future edits to GORM tags.
Record the existing state without recreating the table
Section titled “Record the existing state without recreating the table”We generated the baseline against a separate empty database. EMPTY_URL and
SHADOW_URL both name disposable databases, distinct from the existing target:
ptah migrations generate --db-url "$EMPTY_URL" \ --schema-file schema-v1.sql --schemas public \ --migrations-dir migrations --name customers_baseline \ --shadow-db "$SHADOW_URL"ptah migrations hash --dir migrationsptah migrations baseline --db-url "$DB_URL" \ --migrations-dir migrations --version 1790682270 \ --schemas public --shadow-db "$SHADOW_URL"1790682270 is the version generated in this run; use the version from your
own baseline file when generating a new one. Baseline verification replayed
that file in shadow and compared it with the existing schema before recording
it as applied. Both customer rows remained.
Review the next change
Section titled “Review the next change”The updated GORM model gives Name a size of 128 and adds Locale with size
10, not null, and default en. We made those same changes in schema.sql,
then asked Ptah to generate the migration:
ptah migrations generate --db-url "$DB_URL" \ --schema-file schema.sql --schemas public \ --migrations-dir migrations --name customer_locale \ --shadow-db "$SHADOW_URL"The generated up file contains the locale addition and the name widening.
The existing name column is already non-nullable, so the migration leaves
that constraint in place. We accepted the generated SQL unchanged:
ALTER TABLE "customers" ADD COLUMN "locale" VARCHAR(10) NOT NULL DEFAULT 'en';ALTER TABLE "customers" ALTER COLUMN "name" TYPE VARCHAR(128);The down file narrows the name before dropping locale. Both files include
Ptah’s generated lock_timeout=3s and statement_timeout=30s directives.
We reviewed the pair, then verified the same bytes against newly initialized
databases before deploying them. The supporting files preserve both the
generated output and the accepted migration directory.
Record the checksum and lint with a disposable database so Ptah can read the prior schema:
ptah migrations hash --dir migrationsptah migrations lint --dir migrations \ --dev-url "$SHADOW_URL" --latest 1 --fail-on errorThe check returned zero and reported PG301P as an informational finding:
this varchar widening is a PostgreSQL catalog change, with no table rewrite
or index rebuild. That finding does not promise zero lock acquisition or
establish application latency under load.
Deploy the schema before starting the new application
Section titled “Deploy the schema before starting the new application”ptah migrations up --db-url "$DB_URL" \ --migrations-dir migrations --verify-sumptah schema compare --db-url "$DB_URL" \ --schema-file schema.sql --schemas public --exit-codePtah applied one pending migration and the comparison passed. Both existing
customers received locale en.
The updated application’s startup no longer calls AutoMigrate. We ran it
with a role allowed to read and write customer data and use the ID sequence,
but without CREATE on public. It inserted a customer with a 97-character
name and read all three customers successfully. A separate CREATE TABLE
attempt by that same role failed with permission denied for schema public.
This verifies the separation directly: the migration credentials change the schema; the running application uses the resulting tables.
Test rollback with data that uses the new limit
Section titled “Test rollback with data that uses the new limit”A fresh database replayed both reviewed migrations and matched the desired schema. We then ran the new application there, inserting the same long name, and attempted to roll back to the baseline.
Treat that as a deployment decision before release: once the application stores longer names, rolling back the column width needs a data policy. Silently truncating customer names is not part of this example.
GORM still maps and queries the application data. Ptah now provides the reviewed migration, recorded baseline, lint findings, and schema checks that run before the application starts. Keep the SQL schema and GORM model in the same change, and test the client against the deployed result.
Put Ptah to work
Example files
- expected/change.sql
- generated/1790682270_customers_baseline.down.sql
- generated/1790682270_customers_baseline.up.sql
- generated/1790682291_customer_locale.down.sql
- generated/1790682291_customer_locale.up.sql
- generated/ptah.sum
- measured/app-role.txt
- measured/application-v1.txt
- measured/commands.json
- measured/create-gorm_empty.txt
- measured/create-gorm_live.txt
- measured/create-gorm_reviewed_fresh.txt
- measured/create-gorm_reviewed_live.txt
- measured/create-gorm_reviewed_shadow.txt
- measured/create-gorm_rollback_empty.txt
- measured/create-gorm_shadow.txt
- measured/dependencies.txt
- measured/generate-baseline.txt
- measured/generate-v2.txt
- measured/generated-baseline-data.txt
- measured/generated-baseline.txt
- measured/generated-hash-baseline.txt
- measured/generated-hash.txt
- measured/go-version.txt
- measured/inspect-original.txt
- measured/lint-v2.txt
- measured/lint-with-baseline.txt
- measured/postgres-image.txt
- measured/postgres-version.txt
- measured/ptah-version.txt
- measured/reviewed-app-fresh.txt
- measured/reviewed-app-grants.txt
- measured/reviewed-application.txt
- measured/reviewed-baseline-data.txt
- measured/reviewed-baseline.txt
- measured/reviewed-before-rollback-data.txt
- measured/reviewed-compare-after-failed-rollback.txt
- measured/reviewed-compare-fresh.txt
- measured/reviewed-compare-live.txt
- measured/reviewed-ddl-refused.txt
- measured/reviewed-empty-baseline-compare.txt
- measured/reviewed-empty-compare.txt
- measured/reviewed-empty-down.txt
- measured/reviewed-empty-reapply.txt
- measured/reviewed-empty-up.txt
- measured/reviewed-fresh-app-grants.txt
- measured/reviewed-hash.txt
- measured/reviewed-lint-error-threshold.txt
- measured/reviewed-lint.txt
- measured/reviewed-migrated-data.txt
- measured/reviewed-original-app.txt
- measured/reviewed-rollback-data.txt
- measured/reviewed-rollback.txt
- measured/reviewed-up-fresh.txt
- measured/reviewed-up-live.txt
- migrations/1790682270_customers_baseline.down.sql
- migrations/1790682270_customers_baseline.up.sql
- migrations/1790682291_customer_locale.down.sql
- migrations/1790682291_customer_locale.up.sql
- migrations/ptah.sum
- README.md
- schema-v1.sql
- schema.sql
- v1/go.mod
- v1/go.sum
- v1/main.go
- v1/model.go
- v2/go.mod
- v2/go.sum
- v2/main.go
- v2/model.go
- verified.json