Migrations
Two ways to change a database. Push rewrites a dev database in place and records nothing. A migration is a file you commit and apply once, which is the only safe option for data you cannot drop.
You changed a collection. The table has not changed yet. Which command you run next comes down to one question. Can you drop this database?
Pick one tool per database
| Command | Use it on | What it does |
|---|---|---|
questpie push | a dev database you can drop | Diffs your schema against the live database and applies the DDL now |
questpie migrate:create | everything else | Writes a migration file and its snapshot |
questpie migrate | everything else | Runs pending migrations and records each one |
Do not mix them on one database. push never writes to the migration ledger. A
database you pushed to still reports every migration as pending. Run migrate
there and it repeats a CREATE TABLE that push already ran. That fails.
The dev loop
questpie push --forcePush builds your app, diffs the schema against the live database, then applies
every planned statement. --force only silences the warning. It gates nothing.
Push applies with or without it.
Push prints drizzle-kit's hints. It does not ask. A planned statement that drops a column runs like any other.
Two things stay out of the diff. The ledger table questpie_migrations, and the
pgboss schema the queue adapter owns. A second guard scans the planned
statements. It aborts before applying if one would drop, truncate or alter
either.
Writing a migration
Three commands, in this order:
questpie migrate:create --name add_slug # write the file
questpie generate # import it into the app
questpie migrate # apply itmigrate:create diffs your schema against the snapshots of every earlier
migration. It never connects to a database. You get a migration and a snapshot:
import { sql } from "drizzle-orm";
import type { OperationSnapshot } from "questpie/migration";
import { migration } from "questpie/services";
import snapshotJson from "./snapshots/20260803T120000_add_slug.json";
const snapshot = snapshotJson as OperationSnapshot;
export default migration({
id: "addSlug20260803T120000",
async up({ db }) {
await db.execute(sql`ALTER TABLE "posts" ADD COLUMN "slug" varchar(120);`);
},
async down({ db }) {
await db.execute(sql`ALTER TABLE "posts" DROP COLUMN "slug";`);
},
snapshot,
});Read that SQL before you commit it. This is the last point where a bad diff is cheap.
The generate step is not optional. Codegen scans migrations/ next to your
server config and imports each file into the app. Skip it and migrate sees no
pending work. questpie dev watches that folder and regenerates for you.
Keep the folder where codegen looks
With cli.migrations.directory unset in runtimeConfig(), migrate:create
writes to migrations/ beside your server config. That is the one folder
codegen scans. Point the setting somewhere else and your migrations get
written but never imported.
Apply it as a deploy step
runtimeConfig({ autoMigrate: true }) runs pending migrations while the app
starts. It is off by default. await app.waitForInit() waits for it to finish.
Two runners at once are safe. Each migration runs in its own transaction. The transaction takes a Postgres advisory lock, then re-checks the ledger. A runner that was waiting finds the row and skips the work.
That safety stops at the transaction boundary. An HTTP call or a queue dispatch inside a migration is not rolled back. Keep migrations to schema work.
Run them as their own step anyway. You want the schema change confirmed before new code serves a request. One failed job is easier to read than a replica that crash-loops.
# A Job that must complete before the Deployment rolls
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: my-app:${VERSION}
command: ["bun", "run", "migrate"]
envFrom: [{ secretRef: { name: app-env } }]Most platforms have an equivalent. A release command, a pre-deploy hook, a one-off task. Use it.
What you end up with
questpie migrate:status📊 Migration Status:
Current batch: 3
Executed: 3
Pending: 0Two lists can follow those counters. Executed migrations with their batch and
time, then pending ones by name. Each list prints only when it has rows. Nothing
is pending here, so every migration in the repo has a row in
questpie_migrations. Run migrate:create again on a clean tree and it prints
No schema changes detected and writes nothing.
Where each topic lives
| Topic | Page |
|---|---|
| Every migrate command, its flags and the ledger table | Commands |
| Writing a migration the generator cannot produce | Commands |
| Changing a schema while old replicas still serve | Zero downtime |
| Backfilling rows without holding a transaction open | Zero downtime |
The extensions migrate tries to create for you | Deploying |
| Removing soft-deleted rows for good | Purge |
Next
Zero downtime covers the part a single command cannot do for you. Sequencing a change so the replicas still running old code keep working.