Migrations
You change the database schema the explicit way: write a numbered file under db/migrate/, apply it, and keep db/schema.sql as the snapshot the tests load. Nothing runs against the database until you say so.
Start from the baseline#
snails new --scaffold stamps the template's tables as the first migration — a UTC-numbered baseline — and tells you the next step:
20261111210824_baseline.sqlsnails migrate loadapplying 20261111210824_baseline
1 applied
[snails] snapshot regenerated (3 statements)
[snails] database readyA plain snails new project has no schema, so snails migrate load answers nothing to load. That is normal: a project with no models needs no database file until a feature asks for one.
load is the explicit setup command. It creates the database when it is missing, loads db/schema.sql when one exists, replays pending migrations to record which files applied, then refreshes the snapshot. A SQLite app gets its storage/ directory with the project, so this works before the first boot.
Nothing touches the database on its own. bin/dev never creates, loads, or applies anything: an uninitialized database or a pending migration stops the boot before the build and prints snails migrate load for a fresh database or snails migrate apply once the applied-migration record exists.
Change the schema#
Add a numbered file for every change. The timestamp keeps files in apply order:
-- Slugs give posts a stable URL segment.
ALTER TABLE posts ADD COLUMN slug TEXT;snails migrate plan prints the SQL of everything that has not been applied yet:
-- 20261112090000_add_slug_to_posts (20261112090000_add_slug_to_posts.sql)
-- Slugs give posts a stable URL segment.
ALTER TABLE posts ADD COLUMN slug TEXT;snails migrate apply runs the pending files in order and records them:
applying 20261112090000_add_slug_to_posts
1 appliedapply is the migration half only. It does not regenerate the snapshot; snails migrate dump does that. load does both for an initialized database too.
Keep the snapshot in step#
The tests load db/schema.sql, so regenerate it after every schema change:
snails migrate dump[snails] snapshot regenerated (3 statements)The file header says what it is:
-- Generated by snails migrate dump from db/migrate/*.sql — do not edit.
-- Edit the migration files, then re-run snails migrate dump.snails migrate status shows where you stand at any time:
[snails] 2 applied, 0 pending
20261111210824_baseline applied
20261112090000_add_slug_to_posts appliedsnails migrate verify confirms the snapshot still matches a replay of the migrations. snails migrate check exits non-zero while the database is uninitialized or anything is pending — the command to put before a deploy. The repair command it prints is the one that works for the current environment.
When another system owns the schema#
A project can declare DB_OWNER="external" in pins.toml. That tells every migration command that the database schema belongs to another application: load, apply, and dump refuse instead of writing DDL, and check reports the uninitialized or pending state. Apply changes in the owning system and let check confirm the app sees the expected version.
The alternative, the default, is that this app owns its schema. db/migrate/ and db/schema.sql are then the only writers, and the migration commands are the only path.
PostgreSQL migrations are real DDL runs, not a local file copy. migrate dump and verify replay the migrations into a throwaway schema on the target named by PG_URL or --database, so a dump never writes to the deployed database.
Deploying covers the release-time migration step and the DB_OWNER="external" contract in context.