Status: AcceptedArea: BackendDate: 2026-07-02
Context
Two things make migrations risky during a deploy:- Locks. A blocking
ALTERor an unbatchedUPDATEon a hot table takes anACCESS EXCLUSIVElock, which can stall live API traffic until it finishes. - Deploy order. The API deploys before the workers. If a migration ships in the same PR as the code that needs it, that code can go live before its schema exists.
Decision
- Generate migrations with
alembic revision --autogenerate. Every migration inheritsSET LOCAL lock_timeout = '5s'from the template. - Add a NOT NULL column across separate PRs: add it nullable, backfill with a batched script
(
run_batched_update), then enforce NOT NULL. - The enforce migration’s inline
UPDATE ... WHERE col IS NULLis guarded bysettings.is_development(), so it runs only against a local database. Deployed environments (testing, sandbox, production) run the batched backfill script first, verified before deploy, so they skip the inlineUPDATEand never rewrite a hot table under lock. The followingSET NOT NULLthen doubles as a check that the backfill completed. - Keep migration PRs isolated from application code.
settings in the migration (from
polar.config, the same module migrations/env.py already imports); is_development() is
true only when POLAR_ENV=development, which is local:
Consequences
- A lock-holding migration fails fast at the 5s timeout instead of taking the database down.
- Large backfills run outside the deploy path, in controlled batches.
- Code never ships ahead of the schema it needs.
- CI enforces this: a “Migration Isolation Check” gates which files travel together, and
alembic checkcatches model-versus-migration drift. settings.is_development()is the single decision for what counts as local. A deployed enforce migration cannot rewrite the table; if the backfill was skipped,SET NOT NULLfails the deploy instead.
Alternatives considered
- Add NOT NULL in one step and let the migration’s
UPDATEbackfill every row: rewrites the table under lock during deploy. The inlineUPDATEruns in local development only; the batched script does the real backfill everywhere else. - Ship a migration in the same PR as the code that depends on it: breaks the API-before-workers ordering.
References
server/migrations/script.py.mako,server/migrations/env.py,server/migrations/README.md.server/scripts/helper.py(run_batched_update) and theserver/scripts/backfill_*.pyscripts. CI:.github/workflows/test_server.yaml(migration-check).

