# Migrations (/docs/ship/migrations)

---
title: Migrations
description: 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.
kind: guide
package: questpie
---

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

```bash
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:

```bash
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:

```ts title="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.

<Callout type="warn" title="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.
</Callout>

## 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.

```yaml title="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

```bash
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

| Topic                                                 | Page                                                 |
| ----------------------------------------------------- | ---------------------------------------------------- |
| Every migrate command, its flags and the ledger table | [Commands](/docs/ship/migrations/commands)           |
| Writing a migration the generator cannot produce      | [Commands](/docs/ship/migrations/commands)           |
| Changing a schema while old replicas still serve      | [Zero downtime](/docs/ship/migrations/zero-downtime) |
| Backfilling rows without holding a transaction open   | [Zero downtime](/docs/ship/migrations/zero-downtime) |
| The extensions `migrate` tries to create for you      | [Deploying](/docs/ship)                              |
| Removing soft-deleted rows for good                   | [Purge](/docs/schema/soft-delete/purge)              |

## Next

**[Zero downtime](/docs/ship/migrations/zero-downtime)** covers the part a
single command cannot do for you. Sequencing a change so the replicas still
running old code keep working.
