Schema Evolution Recipes
The schema will change. The recipes that keep migrations backward-compatible, forward-deployable, and reversible — so you can evolve without breaking customer-facing consumers.
Principles
- Backward-compatible. Existing queries should keep working through the migration window.
- Forward-deployable. You can deploy the migration before any code that uses the new shape.
- Reversible. If the migration is wrong, you can roll back without losing data.
- Two-phase for structural change. Rename, type change, column drop — always two phases minimum (add new, populate, switch readers, drop old).
- JSONB for flexibility. When you don't know yet whether a field belongs as a typed column, the JSONB sidecar pattern buys you time.
Adding a column
The easy case. Add nullable; existing queries ignore it.
-- Migration: add jurisdiction column to agreements
ALTER TABLE agreements ADD COLUMN jurisdiction TEXT;
-- Backfill is optional and can be done lazily.
-- Existing queries continue to work; new queries can use the column.
If the column needs a default
For very large tables, adding a column with a non-null default rewrites the whole table on some database engines. Modern Postgres (11+) handles this efficiently for static defaults. For older versions or other engines, add nullable, then backfill in batches, then add the constraint.
Two-phase rename
The most-error-prone pattern. Renaming a column in production breaks every consumer that hasn't been updated yet.
Two-phase recipe
- Phase 1: add the new column. Populate via trigger or batch backfill from the old column.
- Deploy code that writes to both columns.
- Migrate readers one by one to use the new column.
- Once all readers are migrated, stop writing to the old column.
- Phase 2 (later release): drop the old column.
-- Phase 1
ALTER TABLE agreements ADD COLUMN current_end_date DATE;
UPDATE agreements SET current_end_date = current_term_end;
-- (Then deploy code that writes to both; migrate readers.)
-- Phase 2 (after all readers migrated)
ALTER TABLE agreements DROP COLUMN current_term_end;
The cost is days or weeks of overhead and dual-writing. The benefit is zero downtime, no broken consumers.
JSON sidecar pattern
When you're not sure whether a new field should be a typed column yet — because it's customer-specific, experimental, or you haven't seen the full data variance — put it in a JSONB sidecar.
-- Add a flexible sidecar to agreements
ALTER TABLE agreements ADD COLUMN extra JSONB;
-- Use it for fields you'll later promote
UPDATE agreements
SET extra = jsonb_set(COALESCE(extra, '{}'),
'{billing_frequency}', '"monthly"'::jsonb)
WHERE agreement_id = 'agr_x';
-- Query
SELECT agreement_id, extra->>'billing_frequency' AS billing_frequency
FROM agreements;
Once a field stabilizes (used by many queries, mature definition), promote it to a typed column via the two-phase rename pattern.
When NOT to use the sidecar
- Fields that drive joins or indexes — typed columns first.
- Fields with strict type or constraint requirements — typed columns enforce that better.
- Fields that are core to the product. The sidecar is for experimental; the model is for stable.
Adding a new document class
The pattern that runs most frequently in customer deployments. Schema doesn't change — only the doc_class taxonomy expands.
- Add the new class to the taxonomy config (often a JSON or YAML file).
- Configure extraction prompts / templates for the new class.
- Set initial confidence threshold (default 0.85; recalibrate).
- Run on a pilot sample (100 documents); customer-side analysts validate.
- Add to the SLA dashboard.
- Update the working agreement to reflect the new class.
No SQL migration required — the doc_class column already accepts any string. Just consistent usage.
Adding a new obligation type
Similar. The obligation_type column is a string; new types are added to the taxonomy.
- Define the JSONB value shape for the new obligation type.
- Add to the taxonomy config.
- Update extraction prompts to extract the new type when present.
- Backfill on historical documents if the type was always there (e.g., you just started extracting it).
- Update dbt models to surface the new type in the mart layer.
Customer-specific extensions
Some customers have unique fields that don't apply to others. Three patterns:
Pattern 1: Customer-namespaced JSONB
Put it in a sidecar JSONB column, namespace by customer:
-- One sidecar column per entity, key by customer
ALTER TABLE agreements ADD COLUMN customer_extensions JSONB;
UPDATE agreements
SET customer_extensions = jsonb_set(COALESCE(customer_extensions, '{}'),
'{acme_corp,vendor_program_tier}', '"gold"'::jsonb)
WHERE agreement_id = 'agr_acme_x';
Pattern 2: Per-customer extension table
For customers with many unique fields, a separate table:
CREATE TABLE acme_agreement_extensions (
agreement_id TEXT PRIMARY KEY REFERENCES agreements,
vendor_program_tier TEXT,
internal_owner TEXT,
contract_review_status TEXT
);
Heavier-weight but cleaner queries. Useful when the customer's data team will be writing dashboards on top.
Pattern 3: Generic key-value table
Most flexible; weakest performance:
CREATE TABLE entity_attributes (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
customer_id TEXT NOT NULL,
attr_key TEXT NOT NULL,
attr_value JSONB,
PRIMARY KEY (entity_type, entity_id, customer_id, attr_key)
);
Rarely the right call. Use pattern 1 or 2 first.
Migration cadence
Operating recommendations:
- Migrations ship in their own deploy, separate from code changes that use them. Reduces blast radius if a migration fails.
- Reversible migrations — every migration has an "down" path. Some teams enforce this in CI.
- Per-customer migrations for multi-tenant. If a migration is non-trivial, roll it customer-by-customer. Big customers' freezes will force this anyway.
- dbt model rebuilds happen automatically when source schema changes; tests catch regressions before they hit customer-facing dashboards.
- Drop columns slowly. The typical lifecycle is: add → backfill → migrate readers → wait at least one release cycle → drop. Resist the urge to drop in the same release as the rename.
Before every migration deploy, write the rollback. Run the rollback against a copy of production data. Verify it works. The hour spent doing this saves the painful afternoon when the migration is in production and the rollback turns out to be broken.