Adding NOT NULL columns to existing tables
How to safely add NOT NULL columns to populated tables without losing data, with backend-specific notes for SQLite and Postgres.
Adding a new NOT NULL column to a table that already has rows is the most common "your migration won't apply" scenario. umbral catches it earlier than most: makemigrations itself refuses to write the migration when it would brick a populated table. This page covers the safe paths umbral knows about and the backend differences you'll hit.
The constraint, in one paragraph
Both SQLite and Postgres refuse to ALTER TABLE x ADD COLUMN y NOT NULL against existing rows unless they're told what value to put in y for those rows. SQLite is stricter: it also refuses non-constant defaults like CURRENT_TIMESTAMP in ADD COLUMN. Postgres allows DEFAULT now() and backfills inline. The migration engine handles both transparently for the timestamp columns; for everything else you pick the backfill shape.
makemigrations refuses the unsafe shape
When a model gains a NOT NULL field that is not Option<T>, not #[umbral(default = "...")], and not #[umbral(auto_now)] / #[umbral(auto_now_add)] (and isn't the primary key), the autodetector stops with an UnsafeAlter error instead of generating an AddColumn op that would fail on every populated row:
umbral migrate: unsafe column change on `Order.region`: adding NOT NULL column`region` without a default to existing table `order` would fail on everypopulated row. Pick one: (a) make the field `Option<T>`, (b) add`#[umbral(default = "...")]` so the migration backfills, or (c) add`#[umbral(auto_now_add)]` for timestamp columnsThe fix is to adjust the model to one of the three safe shapes below, then re-run makemigrations. The migration is never written until the shape is safe, so there's nothing to delete and nothing reaches the database in a broken state.
The three safe shapes
1. Option<T>: nullable, no backfill needed
pub struct Customer { // ... pub deleted_at: Option<DateTime<Utc>>,}The column is NULL-permitting, existing rows get NULL, no migration drama. Use this when the new field genuinely doesn't apply to historical rows (soft-delete timestamps, optional metadata, etc.).
2. #[umbral(default = "...")]: constant default
pub struct Order { // ... #[umbral(default = "0")] pub refund_count: i32, #[umbral(default = "false")] pub is_archived: bool,}Each default lands in the DDL verbatim (DEFAULT 0 for refund_count). Boolean defaults are translated to integer literals on SQLite, so false renders as DEFAULT 0, not DEFAULT 'false'. The DB backfills existing rows automatically. Works on both backends.
3. #[umbral(auto_now)] / #[umbral(auto_now_add)]: timestamp columns
pub struct Customer { // ... #[umbral(auto_now_add)] pub created_at: DateTime<Utc>, #[umbral(auto_now)] pub updated_at: DateTime<Utc>,}Special-cased by the engine because timestamp defaults differ across backends:
- Postgres: emits
DEFAULT now() NOT NULLin theALTER, backfilled inline by the DB. - SQLite: emits a two-statement migration:
ALTER TABLE x ADD COLUMN y(nullable, no default, since SQLite refuses non-constant defaults)UPDATE x SET y = datetime('now') WHERE y IS NULL
After step 2 every existing row has a sensible value. The column ends up NULL-permitting at the SQLite layer; Rust still types it as DateTime<Utc> (not Option), and every INSERT through the ORM supplies a value via the macro-emitted auto_now arm. Direct-SQL writers could theoretically write NULL; see "Caveats" below.
SQLite's ALTER TABLE ADD COLUMN requires a constant default. CURRENT_TIMESTAMP is computed at evaluation time, not constant, so SQLite rejects it with "Cannot add a column with non-constant default". The full table-rebuild dance (CREATE new, COPY, DROP, RENAME) would let us keep DB-level NOT NULL, but it's heavy machinery for a guarantee Rust already provides at the type level. We picked the lighter compromise.
What NOT to do
- Don't
rm -f *.dbto dodge a failing migration. The framework's CLAUDE.md spells this out: the failure IS the test, deleting your DB hides it and destroys real data. - Don't delete migration files in
migrations/<plugin>/to "regenerate cleanly." The history is part of the schema's audit trail; older deploys depend on it. - Don't ignore an
UnsafeAltererror frommakemigrations. It's telling you the column shape would fail on a populated table. Adjust the model to one of the three safe shapes above rather than reaching for a workaround.
Recovering when makemigrations errors
Because the autodetector refuses the unsafe shape, the common recovery is at makemigrations time, before anything touches the database:
- Read the error. It names the model and column and lists the three safe shapes.
- Adjust the model to one of those shapes (
Option<T>,#[umbral(default = "...")], or anauto_now*timestamp). - Re-run
cargo run -- makemigrationsto generate the now-safe operation, thencargo run -- migrateto apply it.
Nothing was written to disk and nothing reached the database, so there's no half-applied migration to clean up.
Caveats
- Direct-SQL writers can NULL out a "NOT NULL" auto_now column on SQLite when that column was added via
ALTERto a populated table. The Rust ORM enforces non-null on every INSERT/UPDATE; nothing downstream of the ORM sees NULL in practice. If you have raw-SQL writers (analytics jobs, third-party tools), audit them or migrate the column via a table rebuild; the engine doesn't do that automatically. - Postgres users get the cleaner story: every auto_now column keeps DB-level NOT NULL whether added at CREATE or ALTER time. Prefer Postgres for production deploys (the framework already recommends this; see
arch.md §9). #[umbral(default = "...")]strings are passed through verbatim to the DDL as literal SQL. Use single quotes around string defaults yourself if needed (default = "'pending'"); booleans get auto-translated to1/0for SQLite.