og.rsts.dev
← writing

Trunk-Based Development Finally Came for the Database

A builder's playbook for trunk-based development on the database: branching versus migration-as-code, expand/contract in real SQL, online changes that don't lock, and the CI gate, across Azure SQL, Postgres/Neon, Snowflake, and Databricks.

Trunk-Based Development Finally Came for the Database
Contents

The schema release still happens on a Saturday. A maintenance window on the calendar weeks out, the app and database shipped together in one coordinated drop, and a rollback script that was written carefully and tested never. Application code stopped working this way a decade ago: small commits, one trunk, deploy whenever.

The database stayed stuck on big-bang releases for one reason. Trunk-based development depends on a cheap, realistic, throwaway copy to test each change on, and data was too big for that to be cheap. It isn’t anymore. Here is the playbook.

Why the database never got to do this

Branching code is free because source is small: branch in milliseconds, run the suite in isolation, throw it away at zero cost. Data broke all three. A realistic test meant copying production, with time and storage proportional to size, so you did not do it per change. Rollback was “restore from backup,” which is an outage with a nicer name. So you batched: a quarter of changes in one release, and long-lived shared DEV, QA, UAT databases that everyone migrated by hand. A long-lived shared database is a long-lived feature branch in the data tier: it drifts from prod, drifts from intent, and accumulates changes nobody wrote down. You already know long-lived branches are the thing trunk-based development exists to kill.

Branching got cheap

Modern platforms stopped copying bytes. The pattern underneath, wherever it works, is four moves:

  • Append-only, versioned storage. Old pages and the write-ahead log are kept, so any past state is reachable.
  • A branch is a pointer. It records “parent X at this log position” and copies nothing. Instant, free at creation.
  • Copy-on-write divergence. Only the pages you change get new storage; the rest is shared with the parent. Cost scales with what you change, not with database size.
  • Separate compute. The branch gets its own endpoint, so a heavy test never competes with production.

Native copy-on-write versus a full physical copy is the whole story of why this is trivial on some platforms and a project on others. One caveat that drives everything below: branching does not merge data back. A branch is a place to test and discard. Promotion is a separate mechanism, and that mechanism is the point.

Branching tests, migrations promote

This is the distinction people conflate. Data branching versions state: what did the database look like at time T, and can I get an isolated copy to test against. Migration-as-code versions change: the ordered, reviewed steps that take schema N to N+1, and which have already run here (Liquibase, Flyway, SSDT, dbt).

They are two halves of one discipline. The mistake is reaching for a branch and then hunting for a “merge to production” button. There is none. You do not move a branch’s rows into prod; you run the same reviewed changesets against prod, in order, exactly as they ran on the branch. The changelog on main is the definition of the schema. Production is that changelog applied in order. The branch is scaffolding you tear down.

Diagram of the two building blocks of trunk-based development for databases. Across the top a band labeled branching is the place to test; across the bottom a band labeled migrations are what you promote. The flow runs left to right: a golden masked branch forks into an ephemeral PR branch with a time-to-live tag, which feeds a CI stage containing lint, migrate, test, rollback round-trip, and N minus 1 compatibility; that merges to the trunk changelog, which takes a restore point and then migrates production.

This is the operational half of Version Control for Data, which is about where versioned data lives, by shape, across git, the lake, and the lakehouse. This post is how you change it once it lives there.

Expand/contract, in SQL

The trunk has to stay releasable: every commit on main safe to ship at any moment. Code uses feature flags. The database uses expand/contract (parallel change), which turns a scary change into a sequence of boring, individually-safe ones. Take the landmine of splitting customer.full_name into first_name and last_name.

1. Expand. Add nullable columns. Purely additive, so app N and N-1 both keep working, blind to them.

ALTER TABLE customer ADD first_name varchar(255) NULL;
ALTER TABLE customer ADD last_name  varchar(255) NULL;

2. Dual-write. The app writes both old and new, behind a flag. Old readers still see full_name.

3. Backfill as a job, not a migration. A million-row backfill has no business inside a changeset that holds a lock. Batch it, make it resumable and idempotent, run it online:

-- loop in app/pipeline code until 0 rows affected; commit each batch
UPDATE customer
SET    first_name = split_part(full_name, ' ', 1),
       last_name  = split_part(full_name, ' ', 2)
WHERE  first_name IS NULL AND full_name IS NOT NULL
LIMIT  5000;

4. Switch reads behind a view or flag, so the flip and its reversal are one line:

CREATE OR REPLACE VIEW v_customer AS
SELECT id, first_name, last_name /* , ... */ FROM customer;

5. Contract, a release or more later, after a restore point and once nothing reads the old column:

ALTER TABLE customer DROP COLUMN full_name;

The absolute rule: never ship a destructive change in the same release as its replacement. Add the new shape and drop the old one at least one release apart. The same staircase covers type changes, key changes, and moving a table between schemas. The entity model is the floor you renovate while people stand on it, which is the point of An ERD Is the Floor, Not the Ceiling.

Online changes that don’t lock

Expand/contract only helps if the individual steps do not take the table down. The two engines most readers run:

-- Postgres: build the index without blocking writes
CREATE INDEX CONCURRENTLY idx_customer_last ON customer (last_name);

-- Postgres: add a constraint without a full-table scan lock, then validate separately
ALTER TABLE orders ADD CONSTRAINT chk_total CHECK (total >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT chk_total;
-- SQL Server: online, resumable index build (Enterprise / Azure SQL)
CREATE INDEX idx_customer_last ON customer (last_name)
WITH (ONLINE = ON, RESUMABLE = ON);

Lint these in CI so the dangerous ones never merge: squawk for Postgres, Liquibase quality checks or sqlfluff elsewhere. Block a bare CREATE INDEX on a large table, a DROP without approval, or a size-of-data ALTER COLUMN.

Branching, per platform

Where you get a cheap branch, and where you lead with migrations instead:

-- Snowflake: zero-copy clone on immutable micro-partitions
CREATE DATABASE pr_123 CLONE dev_main;
-- or at a point in time:
CREATE DATABASE pr_123 CLONE dev_main AT (TIMESTAMP => '2026-10-18 09:00:00'::timestamp);
-- Databricks: shallow clone shares the parent's data files
CREATE TABLE pr.orders SHALLOW CLONE prod.orders;
-- caveat: a shallow clone breaks if VACUUM removes the files it points at.
-- align VACUUM retention with your max branch TTL.
# Neon / Databricks Lakebase (serverless Postgres): native copy-on-write branch
neon branches create --name pr-123 --parent main

# Snowflake / Databricks: run dbt slim CI against the clone, only modified models
dbt build --select state:modified+ --defer --state ./prod-artifacts

Azure SQL and SQL Server have no cheap native branching, so migration-as-code is the main safety net, not an add-on. Lead with Liquibase or Flyway (or SSDT/DACPAC on all-Microsoft teams). For the realistic test copy: an Azure SQL Hyperscale database copy is fast and storage-snapshot-backed, so it does not scale with size the way a General Purpose copy (a full physical copy) does; on VMs or on-prem, use storage-array snapshots or a virtualization layer like Redgate Clone, Delphix, or Windocks. Build one nightly masked golden copy and fork small databases from it.

The one place you actually merge data is a git-style catalog over the lake: lakeFS, or Nessie over Iceberg, give real branch/commit/merge across many tables. That is where the lakehouse work in From Medallion to Meaning meets this. Everywhere else, promote through migrations.

The CI gate

A migration is a reviewed artifact with a rollback, not a script someone runs by hand. Liquibase keeps them in main, tracks what ran in DATABASECHANGELOG, and checksums them so edited history fails the deploy:

# changelog.d/0042-split-customer-name.yaml
databaseChangeLog:
  - changeSet:
      id: 0042-expand-customer-name
      author: og
      changes:
        - addColumn:
            tableName: customer
            columns:
              - column: { name: first_name, type: varchar(255) }
              - column: { name: last_name,  type: varchar(255) }
      rollback:
        - dropColumn:
            tableName: customer
            columnNames: first_name, last_name

Every PR runs the round-trip against an ephemeral masked branch, and a missing or broken rollback fails the build:

liquibase update                    # apply to the PR branch
liquibase rollback-count --count 1  # prove the rollback works
liquibase update                    # re-apply; also run app N-1 tests here

The rules that make the trunk releasable, the same ones GitOps gave infrastructure in The Domino That Already Fell:

  • Trunk is the only truth. Nothing reaches prod except through the changelog. A hand hotfix is drift and gets caught.
  • Every change is compatible with app N and N-1. That is what “always releasable” means here.
  • Changesets are immutable once merged. Fix forward; checksums enforce it.
  • Test on masked, realistic volumes, never an empty dev schema where every query is fast.
  • Prove rollback in CI (the round-trip above), do not assume it.
  • Restore point before every deploy: a clone, a Time Travel mark, or a branch at the pre-deploy position.
  • Nightly drift diff of prod against trunk (liquibase diff / schema compare) alerts on divergence.
  • Every branch has a TTL and an owner. A branch with no expiry is a growing storage bill.

Those rules let you delete the shared DEV/QA/UAT databases. A nightly masked golden copy is the parent; each PR forks a short-lived branch, tests one change, and expires.

The tradeoff, straight

  • Schema changes have real blast radius. A bad stateless deploy rolls back in seconds; a bad migration can corrupt data no rollback fully restores. Rollback fear is earned.
  • Masking is not optional. A branch of production is production data, PII included. “Branch prod per PR” is data exfiltration unless masking is applied at branch creation and verified.
  • But size and surprise are what make a change dangerous, and trunk-based development attacks both: changes get small, get tested on prod-shaped data, expand/contract removes the atomic rename-and-drop, and a restore point bounds the worst case.
  • The discipline is not the tax on speed, it is what earns it. Same instinct as Don’t Vibe Your Infrastructure: fast on stateful systems is a function of guardrails, not their absence.

Measure it like delivery

Port the discipline, port the scoreboard. Track DORA on the database:

  • Deployment frequency: up, from a Saturday a quarter toward a changeset a day.
  • Lead time: merged change to prod, down from weeks to hours.
  • Change failure rate: deploys needing a fix-forward or restore, down (smaller changes, tested on real data).
  • Time to restore: toward a flag flip or a restore point.
  • Drift incidents: nightly diffs where prod and trunk disagree, at zero.

The Saturday window was never safety. It was the absence of a cheaper option, dressed up as prudence. The cheap realistic branch changed that. The database was never the exception; it was waiting for the storage to catch up.


The companion read is Version Control for Data, on where versioned data lives across git, the lake, and the lakehouse. This piece is the other half: how you change it without a Saturday.

I write about AI-assisted development, enterprise architecture, and building in regulated environments. If you are dragging your database out of the big-bang-release era, find me on X @orestesgarcia or LinkedIn /in/setsero.