Skip to content

Migrations ​

A migration is a SQL file that changes the database: it creates a table, adds a column, fills in data. Migrations run in order, each one once, so every database you have (yours, your colleague's, production) goes through the same changes.

What you'll learn

  • What a migration file looks like
  • How Preá writes migrations from your models
  • How migrations run: at startup or with prea migrate
  • What a safe setup for production looks like

A migration file ​

migrations/
├── 0.0.1_create_task_table.sql   written by Preá from the models
├── 0.0.2_add_due_date.sql        written by you
└── 0.1.0_backfill.sql            versions may skip numbers
sql
alter table task add column due_date date;

-- prea:down
alter table task drop column due_date;
  • The name is <version>_<name>.sql. Versions are semantic (0.0.1) by default, or timestamps (20261013120000) with database.migrations.version: timestamp. Don't mix the two in one folder.
  • The SQL runs exactly as written, including functions with $$ bodies.
  • -- prea:down is optional. Below it goes the SQL that undoes the migration. Without it, prea migrate down refuses to undo that migration, rather than undoing half of it.
  • -- prea:no-transaction, on the first line, runs the file outside a transaction, which create index concurrently needs. Put one such statement per file: PostgreSQL runs several statements sent together in one transaction, where concurrently fails. Otherwise each migration runs in a transaction: if it fails, none of it stays, and the next ones don't run.

Written from your models ​

In the dev profile of a new project, two settings are on:

yaml
# config/application-dev.yaml
database:
  migrations:
    from_models: true   # a model change becomes a migration at startup...
    on_start: true      # ...that is applied right away

Change a model, save, and prea dev restarts. Preá compares your models with the database and writes the difference as a new migration:

sql
-- migrations/0.0.2_alter_task_table.sql
-- Generated by prea from the models: review it before committing.

alter table "task" add column "due_date" timestamptz;

-- prea:down
alter table "task" drop column "due_date";

It finds missing tables, columns and indexes, and changes of type or nullability. Review every generated file before you commit it. It is a starting point, and you own it from then on.

The file is named after what it does: create_task_table, alter_task_table, or create_list_table_and_alter_task_table when one start changes several tables.

Why isn't a removed field dropped?

A column that exists in the database but not in the model appears in the migration commented out. Dropping a column destroys its data, so that decision is yours: uncomment the line if you mean it.

When there are pending migrations, Preá doesn't generate anything: they might already contain the change. Apply them first.

Write one yourself ​

For changes a model can't express, like moving data or creating a view, create an empty migration and write the SQL:

bash
prea migrate create backfill_titles
created migrations/0.0.3_backfill_titles.sql

The CLI ​

CommandWhat it does
prea migrate upapplies every pending migration
prea migrate up 0.0.2applies the pending ones up to that version
prea migrate downundoes the applied migration with the highest version (down 3: the three highest)
prea migrate statuslists each migration as applied, with the date, or pending
prea migrate validatechecks the files without running them, for CI
prea migrate create <name>creates the next empty migration

Pass a profile before the command: prea migrate --profile prod status.

0.0.1_create_task_table                  applied 2026-10-15 11:59:10
0.0.2_add_due_date                       pending

prea migrate builds your app and runs it in a special mode: instead of serving, the app runs the command with its own configuration and database. You never repeat the connection settings for the CLI, and a migration never needs the app's other secrets.

At startup ​

When the app starts, the database steps run in this order:

  1. from_models writes a migration with the difference.
  2. on_start applies the pending migrations.
  3. schema checks or changes the tables, as Models & schema describes.

Preá keeps track of the applied migrations in the prea_migrations table, one row each:

versionnamechecksumapplied_at
0.0.1create_task_tabled9c7076a…2026-10-26 19:59:50+00
0.0.2alter_task_table44091f9a…2026-10-26 20:12:03+00

The version is written as in the file name, and prea migrate down deletes the row of the migration it undoes. An advisory lock keeps two instances from migrating at the same time, so starting several copies of the app at once is safe.

Applied migrations don't change ​

Deleting, renaming or editing a migration file doesn't undo what it did to the database. So before migrating, Preá checks each row of prea_migrations against the file with its version, and stops the app, and prea migrate, if the file is missing, has another name or its SQL changed:

database: the migrations in migrations/ don't match the database:
  0.0.2_alter_task_table: applied, but its file is missing
  0.0.3_create_list_table: applied, but its version is now 0.0.3_add_due_date.sql
  0.0.4_create_tag_table: applied, but its SQL changed since
Deleting, renaming, editing or replacing an applied migration doesn't undo it. Restore the file (e.g. git checkout); to undo the migration, run prea migrate down, then delete the file

The name catches a new file that takes the version of a deleted one, which would otherwise never run. The checksum covers what ran, the part above -- prea:down, so you can still add or fix the down of an applied migration. Line endings and blank lines around the SQL don't count; any other change does, comments included.

Migrations only run forward ​

A new migration must have a version above the highest one applied. If a pending migration is older, which happens after merging two branches that each added one, Preá stops the app and prea migrate instead of running it out of order:

database: the migrations in migrations/ don't match the database:
  0.0.2_add_due_date: pending, but older than the last applied migration, 0.0.3_create_list_table
Migrations only run forward: give the pending one a version above 0.0.3

Rename the file to the next free version, like 0.0.4_add_due_date.sql, and start again. For the same reason, prea migrate down undoes from the highest version, so whatever it leaves pending stays above what's applied.

Change an applied migration ​

To change an applied migration, undo it first:

bash
prea migrate down
# edit or delete the file

In production ​

yaml
# config/application-prod.yaml
database:
  schema: validate          # the tables must match the models, or the app won't start
  migrations:
    from_models: false      # never write files in production
    on_start: true          # or false, and run prea migrate up in your deploy

Apply migrations either at startup (on_start: true) or as a deploy step (prea migrate up), and keep schema: validate so an app never runs against tables it doesn't expect.

from_models can't be combined with schema: update, create or create-drop, because one would apply directly what the other records. The app stops and says so.

Next steps ​

Released under the MIT License.