Skip to content

Latest commit

 

History

History
70 lines (50 loc) · 6.36 KB

File metadata and controls

70 lines (50 loc) · 6.36 KB

ORM migration default values (the type-zero-value trap)

Last updated: 2026-05-27 - first encountered in Task priority (#64, v2.10.5)

When you add a non-nullable column through an ORM migration, the default that backfills existing rows comes from the column's SQL DEFAULT, and the ORM derives that from the language type's zero-value (0, "", false) - not from any model-level initializer or default expression you wrote in code. If you want existing rows to backfill with a specific non-zero value, you have to configure the database default explicitly, then check that the generated migration actually contains it.

Mental model

There are two completely separate "defaults" in play, and they live in different worlds. The runtime default (a property initializer like = 4, a constructor, a factory) only affects objects your code creates in memory. The schema default (DEFAULT 4 in the column definition) is what the database stamps onto a row when an INSERT omits the column, and crucially what an ALTER TABLE ADD COLUMN writes into every pre-existing row. A migration is generated by diffing model metadata against the schema; it never executes your runtime initializer, so unless the default is part of the model metadata, the migration falls back to the type's zero-value.

Why it exists / what problem it solves

A migration has to be deterministic and runnable without your application code - it is just SQL (or a SQL-generating script) that a tool applies to a database, possibly years later, possibly from a CI runner that never instantiates your domain objects. So the migration generator can only use information that is part of the schema model, not arbitrary runtime behaviour. A C# field initializer, a TypeScript default parameter, a Python __init__ - none of those are visible to "produce the DDL for this table". The ORM therefore needs a non-nullable column to have some default for the backfill, and the only thing it can infer without you telling it is the type's zero-value.

How it actually works

Your model:  public int Priority { get; set; } = 4;   // runtime initializer
                         │
                         │  the migration generator sees: "int, non-nullable"
                         │  it does NOT see the "= 4"
                         ▼
Generated migration:  ADD COLUMN Priority INTEGER NOT NULL DEFAULT 0
                         │
                         ▼
Applied to a table with existing rows:
   every existing row  ->  Priority = 0     (the int zero-value, NOT 4)
   new rows your code inserts  ->  Priority = 4   (your initializer ran in memory)

   Result: a split-brain default. Old data says 0, new data says 4.

The fix is to put the default into the model metadata so the generator emits it:

modelBuilder.Entity<TaskModel>().Property(t => t.Priority).HasDefaultValue(4);
// regenerate -> ADD COLUMN Priority INTEGER NOT NULL DEFAULT 4
// existing rows backfill to 4; new rows are 4; consistent.

Common misconceptions

  • "My property initializer (= 4) sets the database default." It does not. It only affects in-memory objects your code news up. The migration cannot see it.
  • "Non-nullable means I do not have to think about the default." The opposite - non-nullable is exactly when the backfill value matters, because every existing row must get something and the ORM will pick the zero-value if you stay silent.
  • "It worked in my tests, so the default is fine." Tests usually run against a fresh schema where the column default is irrelevant (every row is inserted by code that sets the field). The zero-value backfill only bites on a table that already has rows - i.e. production, on the day you deploy.
  • "0 / empty string is harmless." It is harmless only if it is a valid value. If your valid range starts at 1 (like a P1-P4 priority), or an empty string is not a real state, the backfill silently writes invalid data that no validation ever rejected.
  • "The generated migration is correct by definition." Migrations are generated, not divine. For any non-zero default, read the generated DDL and confirm the defaultValue before applying it to real data.

When it matters in practice

  • Adding a status/priority/tier column whose valid values start at 1, or use a sentinel other than 0 for "none" (the #64 case: existing tasks needed to land on P4=4, but the migration wrote 0).
  • Adding a non-null boolean flag that should default to true (e.g. is_active) - the zero-value is false, so every existing row silently becomes inactive.
  • Adding a non-null string column with a meaningful default (a role like "member") - the zero-value is "" (empty), not your intended string.
  • Any column added to a table that already holds production rows. On an empty/dev table the bug is invisible, which is exactly why it slips through.

Configuration in common stacks

Stack Set the schema default Notes
EF Core (.NET) modelBuilder.Entity<T>().Property(x => x.Field).HasDefaultValue(v) in OnModelCreating, then regenerate the migration The = 4 property initializer is runtime-only and ignored by the migration generator. Verify the migration's defaultValue:.
Rails (ActiveRecord) add_column :table, :field, :integer, null: false, default: 4 (or change_column_default) The migration DSL takes the default directly; a model-level default (e.g. an after_initialize) does not touch the schema.
Django default=4 on the field drives the backfill at migrate time; db_default=4 (Django 5+) sets a real SQL default. Older Django runs the Python default per existing row during the migration. A model default is used for the backfill, but it is applied by Django, not necessarily stored as a SQL DEFAULT unless you use db_default.

The cross-cutting rule: decide the backfill value deliberately, express it where the migration tool can see it (schema metadata / the migration DSL), and read the generated migration to confirm it before running against data you care about.

Further reading