Branching and the Migrations Workflow
The idea in one sentence
Section titled “The idea in one sentence”A Supabase branch is a full preview copy of your project tied to a Git branch or pull request — its own isolated Postgres schema, and optionally its own seeded data — so you can test schema changes in isolation before they ever touch production, the same way a preview deployment isolates your app code.
What a Supabase branch actually is
Section titled “What a Supabase branch actually is”Without branching, “testing a schema change” usually means running it against a shared staging database, or worse, against production directly. A Supabase branch instead provisions a genuinely separate Postgres instance for the lifetime of a Git branch or pull request: its own schema, its own Data API, its own Auth and Storage, and — if you ask for it — a clone of your production data.
# Create a persistent preview branch (stays around across pushes, not tied to a single PR)supabase branches create --persistent
# Create a preview branch for a pull request, cloning production data into itsupabase branches create feature-add-comments --with-data
# List every preview branch on the linked projectsupabase branches listThis is not a feature flag toggled inside a single shared database — it is a fully separate database, which is exactly why it is safe to run destructive migrations, seed odd test data, or try a risky alter table against it without any risk to production.
Migrations: the file format that ties branches to production
Section titled “Migrations: the file format that ties branches to production”A branch only stays useful if the schema changes tested on it can be replayed reliably somewhere else. That is what migrations are for — plain SQL files, checked into version control, applied in order.
# Create a new, timestamped SQL migration file under supabase/migrations/supabase migration new add_comments_table
# Apply all pending local migrations to the linked project or branchsupabase db push
# Capture a schema change made through the Studio GUI as a migration file after the factsupabase db diff --schema publicsupabase migration new gives you an empty, timestamped .sql file to write your create table or alter table statements into by hand. supabase db diff covers the opposite case: someone made a change by clicking through the Studio GUI, and you want that change captured as a proper migration file instead of living only in the database. Either way, supabase db push applies every migration that has not yet run against whichever project or branch it is pointed at — the same command, whether the target is a preview branch or production.
Shipping migrations through CI, not by hand
Section titled “Shipping migrations through CI, not by hand”The value of branching mostly disappears if, after testing a migration on a branch, someone then opens a SQL editor and pastes it into production by hand. That step is exactly where typos, skipped files, and out-of-order migrations creep in. The fix is to make supabase db push part of the same CI pipeline that deploys your application code, so schema changes ship automatically and in order whenever a pull request merges.
# A representative CI step — runs after tests pass, before or alongside the app deploysupabase link --project-ref "$SUPABASE_PROJECT_REF"supabase db pushWith this in place, a migration file merged to the main branch is applied to production the same way every time, by the same automated process that already tested it against a preview branch — no human running ad hoc SQL against a live database, and no environment silently drifting out of sync with what is in version control.
Checking before shipping: --dry-run and db lint, proven for real
Section titled “Checking before shipping: --dry-run and db lint, proven for real”A CI step that just runs db push blind is still one bad migration away from a broken production database. Two real, non-destructive checks belong before it. First, --dry-run — lists exactly what would be applied without touching anything:
$ supabase db push --db-url "postgresql://...127.0.0.1:55322/postgres?sslmode=disable" --dry-run --yesDRY RUN: migrations will *not* be pushed to the database.Would push these migrations: • 20260929070903_fnd_capture_courses.sql • 20260929072708_fnd_add_note_to_widgets.sql{"upToDate":false,"dryRun":true,"migrations":[...],"message":"Finished supabase db push."}Confirmed against the real database afterward — the listed migrations were not applied; the column one of them adds does not exist yet. This lesson’s Foundations counterpart proves the full real migration workflow (migration new, db diff -f, db push, migration list) and db lint output end to end against this same local stack — this lesson does not repeat that proof, only the CI-shaped addition above it: a --dry-run step, and a db lint --fail-on error step, both run before the real db push in the same pipeline, so a broken or unexpectedly large migration is caught in CI output rather than by whatever the migration actually does to production.
supabase db push --db-url "$SUPABASE_DB_URL" --dry-runsupabase db lint --db-url "$SUPABASE_DB_URL" --fail-on errorsupabase db push --db-url "$SUPABASE_DB_URL"flowchart LR
pr["Git branch / pull request"] --> branch["Supabase preview branch (isolated schema copy)"]
branch --> mig["Migrations applied and tested on the branch"]
mig --> merge["Pull request merged"]
merge --> ci["CI runs supabase db push"]
ci --> prod[("Production Postgres")] Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant, asked to “set up the CI step that deploys database changes,” produced this:
🐛 Spot the bug
# An AI assistant's suggested CI step for shipping migrationssupabase link --project-ref "$SUPABASE_PROJECT_REF"supabase db push --yesShow the answer
This runs without asking questions, which in a CI pipeline is exactly the wrong property for a step with no prior check. The --yes flag skips the interactive “do you want to push these migrations?” confirmation this lesson’s earlier examples show by default — reasonable for a non-interactive pipeline, but only once something else has actually verified what is about to ship. Nothing here does: no --dry-run to print what would be applied, no db lint to catch a typing error before it reaches production, and no distinction between a migration that already ran safely on a preview branch versus one that has never been tested at all. The fix is the three-line pipeline proved above: --dry-run first (prints, does not apply), db lint --fail-on error second (fails the build on a real schema error before touching the database), and only then the real db push — turning “trust the CLI and hope” into “verify, then apply,” the same shift from ClickOps to reviewable process this whole module keeps returning to.
Mental Model
Section titled “Mental Model”Treat CI’s db push step the same way you would treat a deploy of application code: something should verify the change before it runs against anything real, and the check should fail loudly and stop the pipeline rather than let a bad migration through because “it usually works.” --dry-run and db lint are that verification step for schema changes specifically — cheap, non-destructive, and both proven above to run against the same real database the actual push will target, so they are testing the real thing, not a simulation of it.
Trade-offs
Section titled “Trade-offs”- Branching gives you a fully isolated database to test a risky migration against — at the cost of needing a hosted, linked project (proved throughout this course: the local CLI stack alone cannot simulate branching, since it has no concept of a hosted preview environment).
--dry-runis fast and completely safe to run on every single CI run — at the cost of only checking “what would be applied,” not “will this actually succeed” — a migration that passes dry-run can still fail for real ondb pushif, for instance, a table it depends on was dropped since the migration file was written.- Automating
db pushthrough CI removes the human error of ad hoc production SQL — at the cost of making the CI pipeline itself a high-trust target: whatever credentials let CI rundb pushagainst production can do anything any migration file could do, which is exactly why those credentials deserve the same scrutiny a service_role key gets elsewhere in this course.
Common Misconceptions
Section titled “Common Misconceptions”- “
--dry-runactually tests whether the migration will succeed.” Disproved by what it demonstrably does above: it only prints which migration files are pending, without connecting them to Postgres for real execution —db lintand the eventual realdb pushare what actually exercise the SQL. - “
--yesis a shortcut that skips something optional.” It skips the one interactive confirmation standing between a CI pipeline and applying migrations with zero human review — appropriate only once--dry-run/db linthave already done that job programmatically, as the SpotTheBug above shows going wrong. - “Branching and migrations are two unrelated CLI features that happen to live in the same module.” They are the same workflow at two different stages: a migration is tested in isolation on a branch, then the identical file — unchanged — is what CI applies to production; branching without migrations to test would have nothing repeatable to carry forward.