DEV Community

Cover image for Supabase pg-delta migration guide: adopt declarative schemas safely
Dave Kurian
Dave Kurian

Posted on Originally published at otf-kit.dev

Supabase pg-delta migration guide: adopt declarative schemas safely

A schema diff can be technically valid and still remove data you meant to keep. That is why switching an existing Supabase project to Declarative Schemas 2.0 needs a review plan, not a blind toggle.

The Supabase CLI now uses pg-delta for this workflow. It can detect schema changes that the previous migra-based diff missed, including changes to RLS policies, grants, comments, domains, and view settings. That broader coverage is useful, but it also makes the generated migration worth reading carefully: pg-delta prefers representing the schema accurately over guessing that a destructive-looking change was intended to preserve data.

If you already use declarative schemas, you can test the new engine on a branch and compare its first generated migration before applying anything. If your project uses hand-written migrations, that workflow does not need to change. This guide is for teams choosing to move an existing project to the new diff engine.

Byte lifts a structural blueprint from a database vessel and aligns editable schema sheets beside a separate branching migration-history track.\n\n*Establish the database baseline, keep the desired schema editable, and compare it with the applied migration history.*\n\n## Know which files are the source of truth\n
Supabase declarative schemas keep SQL definitions in supabase/schemas/. The CLI compares those files with migration history and writes a new SQL migration. The schema files are the desired definition; the migration history records changes that have been applied.

That distinction matters when you start from a project that already has a live database. If the project has no schema files yet, you can export its current database structure with:

supabase db schema declarative generate --linked
Enter fullscreen mode Exit fullscreen mode

Before generating, confirm that the CLI is linked to the intended project and that its migration history is current. Supabase says that a project without migration history should run supabase db pull once to establish a baseline. A declarative pull refreshes the schema files; it does not, by itself, record a change that is already live as an applied migration. If a dashboard or SQL-editor change is already present in production, record it in migration history first. Otherwise a later push can try to create an object that already exists.

If the repository already has declarative schema files generated with migra, the first pull with pg-delta can produce a catch-up migration. Treat that as a review event. Do not apply the output just because the command completed successfully.

Enable pg-delta on an existing project

The Supabase announcement documents pg-delta for Supabase CLI 2.119 and later. Add this block to the project's supabase/config.toml:

[experimental.pgdelta]
enabled = true
Enter fullscreen mode Exit fullscreen mode

New CLI projects get Declarative Schemas 2.0 by default. Existing projects remain on migra until they opt in. Keep the change in a dedicated branch so the diff engine, schema files, and generated migration can be reviewed together.

If your team depends on migra while evaluating the switch, the CLI configuration can keep it enabled with pg-delta disabled. Avoid mixing the engine switch with unrelated schema changes: one focused pull request makes it easier to identify whether a changed statement came from the new diff behavior or from application work.

Generate a migration without applying it

Once the schema files represent the intended state, ask the CLI to generate the migration but leave it unapplied:

supabase db schema declarative sync -f pg_delta_baseline --no-apply
Enter fullscreen mode Exit fullscreen mode

This writes a plain SQL file under supabase/migrations/. Read it as a database change, not as generated scaffolding. Start with the operations that can remove or rewrite data:

  • Does a dropped column have a replacement, and has the application stopped reading it?
  • Does a table or policy change widen access, narrow access, or change which role can act?
  • Does a view preserve its intended owner, grants, and security_invoker behavior?
  • Are extension-managed objects changed through their extension's API?
  • Did the tool flag unsupported objects or destructive statements?

A renamed column is a useful example. The engine may represent it as a drop followed by an add because it cannot know that the two names refer to the same data. Applying that SQL unchanged can lose the old column's values. Rewrite the migration to use a safe rename or a staged expand-and-contract change, then review the result again.

The same caution applies to policies and grants. More complete coverage can surface access changes that were missing from earlier diffs. Check the generated SQL against the intended access model and test it with the roles your app actually uses. A green command exit is not evidence that the resulting permissions are right.

Make unsupported objects fail the build

During the first migration, warnings deserve attention. Supabase documents --strict-coverage as a way to make unsupported objects fail the command, which is a better default for CI than silently accepting an incomplete diff.

Use a staged adoption:

  1. Generate the migration locally with --no-apply.
  2. Review the SQL in a pull request with someone who understands the production schema.
  3. Run the migration against a disposable or staging database restored from representative structure and data.
  4. Exercise key application paths, including RLS-protected reads and writes.
  5. Require strict coverage in CI, and keep the generated migration as a versioned artifact.
  6. Apply to production only through your established migration process, with a rollback and recovery plan.

The point is not to make every migration reversible by magic. It is to make the diff visible early enough that a developer can correct a destructive operation before it reaches production.

Keep the alpha and scope limits in view

Supabase describes pg-delta as alpha, so breaking changes may land between releases. Pin the CLI version used in local work and CI, then re-run the same representative schema cases when you upgrade. Keep a known-good migration output in review so version changes are easy to spot.

The tool generates schema changes; it does not orchestrate a zero-downtime rollout or create a TypeScript schema DSL. Supabase lists support for PostgreSQL 14 through 18, not PostgreSQL 13 or earlier. Storage bucket rows are outside the declarative diff today, even though policies on storage.objects are part of the schema. Do not assume that moving your database structure to schema files has captured every setting managed through the dashboard.

If you use extensions such as pgmq or pg_partman, inspect their generated operations too. The engine is designed to call extension APIs for extension-owned objects instead of dropping and recreating their underlying tables. It can also flag destructive extension operations. Keep the extension's own behavior in the migration review.

Dex pauses a red destructive-looking schema change before it reaches the staging database while Luna records the review and safer staged path.\n\n*Review the generated migration, correct destructive changes, and rehearse the result in staging before production rollout.*\n\n## Choose a safe first cut\n
A migration-engine change is easiest to evaluate when the first cut is narrow: one representative project, one reviewable migration, and a staging pass before production. Start with the objects your application depends on most, especially RLS policies, grants, views, and extension-managed structures. If the output is surprising, stop and investigate before widening the rollout.

For general rollout discipline, see our Supabase migration safety guide. It covers reversible changes and staged deployment practices; this pg-delta adoption adds a separate prerequisite: validate that the new diff engine sees the objects you intend it to manage.

If you want an editable account and dashboard shell around an internal schema-review workflow, OTF's SaaS Dashboard kit is an optional starting point. It is a Vite and Hono starter with a PostgreSQL database, sign-in, projects, teams, and dashboard UI. It does not include Supabase, pg-delta, or a database migration integration.

Sources

Top comments (3)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

"Read the generated migration" is the whole post, and for RLS there are two diffs worth grepping before you apply — both technically valid, both silently move your security boundary:

1. A policy's TO clause. If a diff regenerates create policy ... for select using (...) without to authenticated, that policy now evaluates for every role — anon included. Identical SQL, different blast radius.

2. with (security_invoker = true) on a view. pg-delta now tracks view settings, so an apply that drops it flips the view back to owner-privileges, and every row it selects bypasses the underlying table's RLS.

A cheap gate before db push — assert against the generated file, not just the schema files:

-- expect 0
select tablename, policyname from pg_policies
where schemaname = 'public' and cmd in ('SELECT','ALL')
  and (qual is null or qual !~ 'uid\(\)|is_member');
Enter fullscreen mode Exit fullscreen mode

For an existing project the durable version is a red→green test that runs in CI, so "the schema diff matched" and "the authorization boundary matched" stop being the same assumption. I keep one for the two-user case (no cloud project, npm ci && npm test): github.com/cekuu35/supabase-rls-le...

Collapse
 
davekurian profile image
Dave Kurian •

Both fit the checklist question about policy roles and security_invoker on views. I have not run pg-delta on a live project, so I can't confirm how it writes a policy's TO clause or view options in a regenerated diff. Checking the generated file before db push is the right place to catch either. Do you run your query against the plan or against a staging database after apply?

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

That’s the honest position, and it’s the reason to make the check mechanical rather than a one-time read. The two things you can’t confirm from pg-delta’s docs — whether a regenerated policy keeps its TO authenticated, and whether a view keeps security_invoker = true — are both visible in the generated diff as plain text (to anon / to authenticated, and with (security_invoker = true) on the create view), so a grep over the diff before db push catches that drift cheaply. The one a diff misses is a policy that is present but scoped wrong (using (auth.uid() is not null) instead of = user_id) — it regenerates identically, so only running the two-account probe against the pushed schema catches it. I’d wire that probe into CI next to the diff check: github.com/cekuu35/supabase-rls-le...