QUESTPIE
Ship

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.

View markdown

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

CommandUse it onWhat it does
questpie pusha dev database you can dropDiffs your schema against the live database and applies the DDL now
questpie migrate:createeverything elseWrites a migration file and its snapshot
questpie migrateeverything elseRuns 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 --force

Push 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 it

migrate:create diffs your schema against the snapshots of every earlier migration. It never connects to a database. You get a migration and a snapshot:

src/questpie/server/migrations/20260803T120000_add_slug.ts
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.

Kubernetes
# 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: 0

Two 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

TopicPage
Every migrate command, its flags and the ledger tableCommands
Writing a migration the generator cannot produceCommands
Changing a schema while old replicas still serveZero downtime
Backfilling rows without holding a transaction openZero downtime
The extensions migrate tries to create for youDeploying
Removing soft-deleted rows for goodPurge

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.

On this page