Skip to content

Branching and the Migrations Workflow

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.

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.

Terminal window
# 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 it
supabase branches create feature-add-comments --with-data
# List every preview branch on the linked project
supabase branches list

This 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.

Terminal window
# 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 branch
supabase db push
# Capture a schema change made through the Studio GUI as a migration file after the fact
supabase db diff --schema public

supabase 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.

Terminal window
# A representative CI step — runs after tests pass, before or alongside the app deploy
supabase link --project-ref "$SUPABASE_PROJECT_REF"
supabase db push

With 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 --yes
DRY 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.

scripts/ci-migration-check.sh
supabase db push --db-url "$SUPABASE_DB_URL" --dry-run
supabase db lint --db-url "$SUPABASE_DB_URL" --fail-on error
supabase 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")]
A Git branch flows through an isolated Supabase preview branch, then merges to production

An AI assistant, asked to “set up the CI step that deploys database changes,” produced this:

🐛 Spot the bug

scripts/ai-ci-deploy-db.sh
# An AI assistant's suggested CI step for shipping migrations
supabase link --project-ref "$SUPABASE_PROJECT_REF"
supabase db push --yes
Show 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.

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.

  • 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-run is 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 on db push if, for instance, a table it depends on was dropped since the migration file was written.
  • Automating db push through 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 run db push against 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.
  • “--dry-run actually 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 lint and the eventual real db push are what actually exercise the SQL.
  • “--yes is 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 lint have 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.
What does a Supabase preview branch actually give you?
What is supabase db diff for?
Why does running supabase db push through CI matter more than running it by hand against production?
What does supabase db push do?
A real db push --dry-run against this course's local stack listed two pending migrations and reported "dryRun":true. What did a follow-up psql check confirm, and why does that matter for a CI pipeline?