Running Migrations
Commands
lemmego run migrate up # apply everything pending
lemmego run migrate down # roll back
lemmego run migrate status # what has run and what has not
lemmego run migrate create -n create_users_tableEach of up and down takes --step (-s), and each of the three takes
--driver (-d) and --dsn (-u) to point at a database other than the
one the application is configured with.
What --step counts
Migrations, on both commands. --step 2 on down reverts the two most
recently applied migrations.
With no --step, down reverts the whole of the latest batch — everything
applied by the most recent migrate up. On a project whose schema went up in
one command, that is the entire schema, so prefer an explicit --step unless
you mean it.
lemmego run migrate down --step 1 # the last migration
lemmego run migrate down # the last batchOne transaction per run
up opens a single transaction, runs every pending migration inside it, and
commits once — with a 30-second budget for the whole run. A failure anywhere
rolls back the entire run; there are no savepoints between migrations. down
works the same way.
This is what makes a failed deploy leave a schema that is either entirely
before or entirely after, and what makes
CREATE INDEX CONCURRENTLY impossible — see
The whole batch is one transaction.
How it works
Init()creates theschema_migrationstable if it is absent.- Migrations already recorded there are marked done in memory.
upapplies the pending ones in version order and records them against a new batch number.downreverts in reverse version order — a number of migrations, or the latest batch.
Pointing at another database
The --dsn flag is how you run migrations against a scratch database, which
is the cheapest way to check that a migration is reversible before it reaches
anything you care about:
createdb myapp_scratch
lemmego run migrate up -u "postgres://user@localhost:5432/myapp_scratch?sslmode=disable"
lemmego run migrate down -u "postgres://user@localhost:5432/myapp_scratch?sslmode=disable"
dropdb myapp_scratchUp, down, and up again on a throwaway database catches the two failures a review does not: a down that leaves a table behind because something still references it, and an up that only works on an empty schema.
Which database migrate connects to
migrate resolves its DSN in this order, taking the first that is set:
--dsnDATABASE_URL_UNPOOLEDDATABASE_URL- the
DB_*components —DB_HOST,DB_PORT,DB_USERNAME,DB_PASSWORD,DB_DATABASE,DB_PARAMS
A present-but-empty variable is treated as absent, so a stray line in a .env
does not shadow the next source.
Note DB_PARAMS is separate from the options map in
internal/configs/database.go that the application reads. lib/pq defaults to
sslmode=require, so a local PostgreSQL needs DB_PARAMS=sslmode=disable in
.env or migrations will fail to connect while the application itself is fine.
Never migrate through a connection pooler
DATABASE_URL_UNPOOLED is checked first, and the ordering matters.
Managed providers such as Neon and Supabase publish two URLs for one database:
a pooled one fronted by PgBouncer in transaction mode, and a direct one. Their
quickstarts point you at the pooled URL, so that is usually what ends up in
DATABASE_URL — and it is the wrong endpoint for a migration, twice over.
lib/pq issues every parameterised query on the unnamed prepared statement
over the extended protocol. Under transaction pooling a Parse from one
logical connection can pair with a Bind from another, and the migrator’s own
bookkeeping queries are parameterised. So a schema change can fail partway
through a deploy, reporting a statement nobody wrote:
pq: bind message supplies 2 parameters, but prepared statement "" requires 1It is intermittent, happens in both directions, and can leave a schema half-changed.
Pooled connections are also session-less by design, so anything a migration
does that outlives a single statement — an advisory lock, a SET, a
session-scoped temp table — applies to whichever backend answered rather than
to the migration.
Set both variables and the right endpoint is chosen for you:
DATABASE_URL=postgres://...-pooler.../db?sslmode=require
DATABASE_URL_UNPOOLED=postgres://.../db?sslmode=requireAn explicit --dsn still wins, including when you deliberately want the pooled
endpoint.