Supabase Migrations Done Right: Dashboard Edits to Versioned SQL

  • Monday, October 26, 2026
  • 11 min read

Supabase migrations without the sync errors: how to move dashboard edits into versioned SQL, push schema changes safely, and fix a broken history.

A Supabase migration is a timestamped SQL file in your repository that the CLI applies to every database in order, so local, preview and production all end up with the same schema. Supabase migrations replace the habit of changing tables in the dashboard, which leaves no record of what changed or why. Once the workflow is in place, every schema change arrives as a file somebody can review.

This guide is for founders and small teams running a live product on Supabase, including apps that began in Lovable, Bolt or Cursor and now carry months of untracked table edits. It covers how the workflow fits together, how to move an existing project onto it, what belongs in a migration file, and how to repair a history that has already drifted.

Key takeaways

  • Two systems have to agree: git tracks your migration files, and Supabase tracks which ones have run, in a table called supabase_migrations.schema_migrations.
  • Changing the remote schema through the Table Editor or SQL editor bypasses that history, and supabase db push then starts failing with sync errors.
  • supabase db pull captures the current remote schema as one baseline migration. That is how an existing project joins the workflow.
  • supabase migration repair edits the tracking table only. It applies and reverts no SQL, so it corrects the record rather than the database.
  • Preview branches carry no fixed fee. A branch on the default Micro compute size starts at $0.01344 per hour, billed as usage.

Supabase migrations: the workflow in one page

Three commands cover most of it. You create a file, rebuild your local database from it, then push it to the linked project.

supabase migration new add_invoices_table
# edit supabase/migrations/<timestamp>_add_invoices_table.sql
supabase db reset     # rebuild the local database from every migration
supabase db push      # apply pending migrations to the linked project

Each file is named with a UTC timestamp, which is what fixes the order they run in. The CLI records every applied file in supabase_migrations.schema_migrations on each database, so a fresh preview branch and a two-year-old production project can both be brought to the same schema by replaying the same list.

The patterns for changing a schema without taking the app down are the same on any PostgreSQL database, and our guide to changing a schema safely without downtime covers them. This article is about the Supabase-specific plumbing around them: the CLI, the history table, and the dashboard habit that breaks both.

Why do dashboard edits break supabase db push?

Because the dashboard writes to the database and nothing else. The Table Editor and SQL editor change your remote schema directly, which leaves your migration history describing a database that no longer exists. Supabase's database migrations guide states the rule directly: once you are using migrations, every schema change to the remote database should go through migration files only.

Git tracks files, Supabase tracks what ran

When you run supabase db push, the CLI compares the files in supabase/migrations against the history table on the target database and applies the ones that are missing, in timestamp order. Neither side inspects the actual schema. A column you added by hand is invisible to both.

So the real failure is not that your change was lost. It is that the next migration file assumes a starting point the database no longer matches, and a create table or add column statement collides with an object that is already there.

What the failure looks like

Usually an error from db push suggesting you run supabase migration repair, or a migration that aborts partway because an object it tried to create already exists. On a project where several people have been clicking around, you get both. Repairing it is covered further down; preventing it means ending remote changes through the dashboard.

There is a reasonable compromise. Use the Table Editor against your local stack, then capture what you did with supabase db diff -f describe_the_change. Supabase supports that path explicitly and warns against the same approach on a remote database.

How do you move an existing project onto migrations?

Take a baseline, commit it, then change nothing outside the workflow from that point on. The baseline matters because without it your first real migration tries to create objects production already has.

  1. Install the CLI, run supabase init in your repository, and connect it with supabase link.
  2. Run supabase db pull. This writes a single migration file describing the current remote schema and initialises the history table.
  3. Read that file. For most teams it is the first honest inventory of their schema, and it is where you notice tables with no policies, no indexes and no owner column.
  4. Run supabase db reset locally and check that the app still works against the rebuilt database.
  5. Commit supabase/migrations to git, and tell everyone the production Table Editor is now read-only.
  6. Make the next change as a migration: supabase migration new, edit, reset, push.

Two details catch people out on older projects. If your CLI version reports that the managed schemas were skipped, pull them once with supabase db pull --schema auth and --schema storage, so a trigger on auth.users is not quietly left behind. And check which diff engine you are on, because config.toml decides whether the CLI uses pg-delta or the legacy migra engine, and the two capture different things inside those schemas.

Imperative migrations or declarative schemas?

Imperative migrations are a list of changes. Declarative schemas are a description of the end state, with the changes generated for you. Start imperative, and consider declarative once views, functions and policies become hard to follow across dozens of files.

  Imperative migrations Declarative schemas
What you edit One file per change One file per object, describing the end state
How a migration appears You write it, or db diff writes it Generated by db schema declarative sync
Views and functions Recreated in full on every change Edited in place, much easier to review
Best for Small schemas, backfills, destructive changes Growing schemas with many views and policies
Main catch Logic for one table scatters across many files Diffs ignore the live database and some object kinds

The declarative workflow lives in supabase/schemas/ and produces migrations through supabase db schema declarative sync, set out in Supabase's declarative database schemas documentation. The catch is worth repeating: sync compares your schema files against your migration history, not the live database, so a change made in Studio shows up as no change at all and gets dropped.

On an existing project you also need a baseline migration before the first sync. Without one, the first sync regenerates the entire schema as a single migration that applies cleanly to an empty local database and then fails on db push, because production already has those objects.

What belongs in a migration file, and what does not?

Anything that defines structure or access belongs in a migration. Anything that is data belongs in a seed file or a separate script.

Put these in migrations:

  • Tables, columns, indexes and constraints.
  • Row Level Security: the enable statement, the grants and every policy. Generated apps usually ship none of these, and our walkthrough of the table policies that keep user data private covers what to write for user-owned, team and admin rows.
  • Functions, triggers and the execute grants on them.
  • Enum types, extensions and scheduled jobs.

Keep these out:

  • Seed and test data. Insert, update and delete statements are never captured by a schema diff, and a data statement inside a declarative schema file is an error.
  • Storage buckets. They are rows in storage.buckets rather than schema objects, so a diff treats them as data and skips them.
  • Secrets and API keys. Those belong in environment variables and vault secrets, not in a file in git.
  • Large backfills. Add the column in a migration, then backfill in batches outside it, so one transaction does not hold a lock for several minutes.

How do you test a migration before it reaches production?

Three layers, in rising order of cost: a local reset, a dry run against the real history, and a preview branch that is a full Supabase project of its own.

  • supabase db reset rebuilds the local database from every migration and reruns your seed file. A migration that cannot survive a reset is not finished.
  • supabase db push --dry-run prints the migrations that would be applied without applying them. It is the cheapest way to confirm the CLI agrees with you about what is pending, and it is documented in the CLI reference for db push.
  • A preview branch gives you a separate Supabase instance with its own credentials, built from the migrations in your repository. Branches start with no data or storage objects by default, so seed them before testing anything that touches existing rows.

What does a preview branch cost?

There is no fixed fee. You pay for the compute, egress and storage the branch uses, and a branch on the default Micro compute size starts at $0.01344 per hour according to Supabase's branching usage documentation. Compute credits do not apply to branching compute and branches sit outside the spend cap, so delete them when the pull request closes.

The risk local testing hides

Locks. ALTER TABLE takes an ACCESS EXCLUSIVE lock, which PostgreSQL's explicit locking documentation describes as conflicting with every other lock mode, so the statement waits for open transactions and then blocks everything queued behind it. On a local table with two hundred rows this is instant. On a busy production table it is an outage.

Supabase's own advice for that case is to raise lock_timeout inside the migration file, so the statement gives up instead of queueing indefinitely. Before any migration that rewrites data, also make sure you have a restore you have actually rehearsed rather than a backup setting you have never used.

How do you fix a migration history that has already drifted?

Find out where the two records disagree, capture reality as a migration, then correct the tracking table. In that order.

  1. Run supabase migration list to see which migrations are applied locally, which are applied remotely, and where the two diverge.
  2. If the drift came from remote changes made by hand, run supabase db pull to capture the current remote schema as a new migration file, then commit it.
  3. If a schema change is already present but the history table does not record it, run supabase migration repair --status applied <timestamp>. For a migration recorded as applied that never actually ran, use --status reverted.
  4. Re-run supabase db push --dry-run and confirm the pending list is what you expect before applying anything.

Keep repair for the tracking table and nothing else. It runs no SQL against your schema, so using it to make an error go away leaves the record saying one thing while the database does another.

Be careful with --include-all as well. It pushes every migration the remote history has not recorded, which on a drifted project can mean replaying a change that is already in place.

Rollbacks are forward-only once something is deployed. Locally, supabase db reset --version <timestamp> rewinds so you can fold a change into a single clean file. On production you write a new migration that undoes the change, and you read the generated statements carefully, because down migrations are usually destructive.

Common mistakes

  • Fixing a bug in the SQL editor late at night and not pulling it afterwards. Almost every drifted history starts here.
  • Editing a migration file that has already been pushed. The history records it as applied, so the edit never runs anywhere except on brand new databases.
  • Writing policies in the dashboard. Policies are schema, and if they are not in a migration then a reset deletes them and a new environment never gets them.
  • Putting a large backfill in the same migration as the schema change, so a failure halfway through leaves both half done.
  • Leaving an AI builder pointed at production while you adopt migrations. A regenerated schema will happily reopen whatever the last prompt decided was wrong.
  • Treating migration repair as a fix rather than a correction to the record.

Frequently asked questions

Can I still use the Table Editor?

Yes, against your local database. Make the change there, then run supabase db diff -f describe_the_change to turn it into a migration file you can review. What breaks the workflow is using the Table Editor or SQL editor on your remote project, because those edits never reach the migration history.

Do I need Docker and a local database to use migrations?

For the full workflow, yes. db reset, db diff and local testing all run against the stack the CLI starts on your machine. You can push hand-written migrations without it, but you give up the cheap place to discover that a migration does not apply cleanly.

What happens if a migration fails halfway through on production?

Pending migrations are applied in timestamp order and the run stops at the one that failed, so earlier migrations stay applied and later ones never start. Whether the failed migration left partial changes depends on whether its statements ran inside a transaction, which is one more reason to keep each migration small and single-purpose. Fix the file, test it with a local reset, then push again.

Where should database functions and triggers live?

In migration files, like every other schema object, so that a reset or a fresh preview branch rebuilds them. Deciding what should be a database function in the first place is a separate question, and our comparison of when logic belongs inside Postgres covers the trade-offs and the security settings each option needs.

Can Lovable or Bolt manage my migrations for me?

They can write SQL, and the output is a fair first draft. Treat it like any other generated change: read the file, run it against a local reset, and check what it does to policies and grants. The risk is less about bad SQL and more about a regenerated schema reverting a decision you made on purpose.

Getting your schema under version control

Start with the baseline. One supabase db pull, one commit, and an agreement that nobody touches production through the dashboard puts you ahead of most projects at this stage. After that the habit is small: one change per migration, a local reset before every push, and a dry run before anything reaches production.

If you would rather have senior engineers take an existing project through that transition and hand it back with a clean history, AlgoSmiths works on fixed-price projects with a working demo every week. Our backend and API engineering is where this layer gets built and documented.