Upgrading¶
One rule covers every upgrade path:
Migrate before you serve. The database schema and the code ship together, and
dst serverefuses to start against a schema behind its build.
The refusal is deliberate. A server running on an out-of-date schema answers questions
correctly — and loses every trace, silently, because request_log writes fail inside a
background task where no caller can see them (services/db/schema_state.py). The review
queue, the drift audit, dst test and dst correct are all views over
request_log, so that loss is the whole governance surface. One refused start costs one
command; the alternative costs an audit trail nobody knows is missing.
pip¶
pip install -U dst-core
dst migrate
# then restart your server
dst migrate is idempotent and takes a blocking advisory lock, so concurrent runs
serialize instead of racing. It now says exactly what it did:
migrated 0037 → 0040 — 3 revisions applied (0038, 0039, 0040)
and on a database that was already current:
already at head (0040) — nothing to apply
A brand-new database reports schema created at 0040 instead. The line is the
confirmation that pulling a new version actually needed migrating — "migrated to head"
used to print identically whether three revisions had been applied or none.
dst dev migrates for you
dst dev is Postgres-up → migrate → serve in one command
(services/cli/main.py), so a local development loop never hits the refusal. It is
only the pip install -U + dst serve path that needs the explicit step.
Containers¶
Bump the pinned image tag — never latest — and let the entrypoint migrate:
docker compose -f deploy/docker-compose.yml pull
docker compose -f deploy/docker-compose.yml up -d
DST_MIGRATE_ON_START defaults to true: the entrypoint waits for
DATABASE_ADMIN_URL to accept connections (up to two minutes), runs dst migrate,
then serves (docker/entrypoint.sh). That is the right shape for compose and
single-instance deploys.
Orchestrated deploys set DST_MIGRATE_ON_START=false and run dst migrate once
per release instead — helm does it in a pre-install/pre-upgrade Job, Cloud Run in a Job
or pipeline step. See Deploying.
The container path is not guarded by the CLI refusal
The entrypoint execs uvicorn directly rather than going through dst serve, so
a container started with DST_MIGRATE_ON_START=false and no release-step
migration will start on a behind-head schema. What catches it there is /ready:
it reports "status": "degraded" with the schema state spelled out, and it is the
only signal, so point readiness probes at /ready and not /health.
{"status":"degraded","db":"ok","schema":"BEHIND — the database is at 0037, this build needs 0040 (3 unapplied)", …}
What the refusal looks like¶
Run dst serve against a schema that has not caught up and you get this on stderr,
exit code 1, with no server started:
error: the schema is behind this build — this database is at 0037, this build needs 0040 (3 unapplied migrations: 0038, 0039, 0040).
Serving anyway loses the audit trail in silence: answers are served correctly and get a request_id, but every request_log write fails in a background task where no caller can see it — and the review queue, drift audit, `dst test` and `dst correct` are all views over request_log.
Run `dst migrate` (or `dst dev`, which migrates and then serves), then start the server.
The fix is the last line: run dst migrate, then start the server again.
Two states that look similar are not refused, on purpose:
- ahead — the database carries a revision this build has never heard of. That is older code on a newer schema, which is the safe deployment order (expand the schema, then roll the code), so it starts normally.
- unknown — the question could not be asked, usually a database still coming up. A slow database is not a broken one.
After upgrading, plan may print ! lines¶
dst plan compares your files against the server, so the one thing it structurally
cannot see is a release that changes what an unchanged file means. Those changes
leave a one-line notice on exactly the lenses they moved, and plan renders it under the
lens, prefixed with !:
tox: unchanged
! generation temperature: this lens generates at 0.0 now and generated at 0.2 before this upgrade — `temperature: 0.0` in its config had no reader until 2026-08-06 (answer_mode: balanced supplied 0.2) and is live now. Nothing to fix if 0.0 is what it meant; remove `model.temperature` from lens.yaml to go back to 0.2. Clears on the next apply.
A ! line is not a diff and not an error: there is nothing in your files to fix, and
plan still exits 0. It is telling you that behavior moved underneath a file that did
not. Read it, decide whether the new interpretation is what the lens meant, and either
leave it or edit the file.
dst apply prints the same notice once as a warning — so an operator who applies
without planning first is not the one person it never reaches — and publishing clears
it (services/lenses/store.py). The notice therefore lives until its owner next applies,
and a project created after the change never sees it at all.
Downgrading¶
Rolling the code back¶
A stored lens bundle stays readable by the release that wrote it. Storage omits unset
keys rather than writing null (services/lenses/store.py), so an older build reading a
newer row falls back to its own defaults instead of raising on a type it does not expect.
That is what makes rolling a release back survivable: the previous version can still
read, serve, and plan the lenses the newer one published.
The guarantee is one release back, and it is a compatibility promise about payload shape, not a general time machine. Do not assume a bundle written today loads under a build from several releases ago.
Rolling the schema back¶
Migrations carry downgrades, so the schema itself reverses with alembic:
uv run alembic downgrade 0037
Some downgrades are deliberately no-ops because the data they would restore no longer exists to restore — a migration that stripped null keys has nothing to put back, and one that recomputed derived digests lets the next apply recompute them again. Reversing the schema is therefore not always the same as reversing the data.
When to restore instead¶
Reach for the backup rather than a downgrade when the problem is in the payload and
not the schema — that is the case alembic downgrade cannot help with, because the rows
themselves are shaped for the newer contract. Restore from pg_dump with the same
DST_SECRET_KEY the dump was taken under; a restore under a fresh key produces a
working-looking install whose stored warehouse credentials are permanently undecryptable.
See Backup & restore.
Back up before every upgrade, like any schema-owning app. It is the only step on this page that cannot be undone by another command.