Local development workflow
Set up and run your day-to-day local development workflow with the Supabase CLI.
This guide walks through two common starting points for local development with the Supabase CLI, and shows how they converge into the same daily workflow. By the end, you'll have a ./supabase directory in your repo that anyone can clone to recreate the full project, locally or on a fresh remote instance.
There are two starting points, both leading to the same place: database schema and migrations tracked in version control, with seed data for local development.
- Move an existing project to local development: you have a project on the Supabase platform and want to bring it into a proper local development workflow.
- Start a new project from scratch: you're building locally and will eventually push to a remote instance.
Before you begin#
You need the Supabase CLI installed and a Docker-compatible runtime running. If you haven't set these up yet, see Install and run the CLI for installation across macOS, Windows, and Linux, and for the details of what supabase start brings up and how to access each service.
Keep in mind that the local stack is for development only. It is not hardened for production use and must never be exposed to external traffic. It has no TLS, no rate limiting, and default credentials. Use it to develop and test, then deploy to the Supabase Platform or a proper self-hosted setup for anything beyond that.
`supabase` vs. `npx supabase`
How you invoke the CLI depends on how you installed it:
- Installed globally with Homebrew or Scoop: run
supabase <command>. - Added as a project dependency with npm, pnpm, yarn, or bun: run it through your package runner instead, for example
npx supabase <command>(orpnpm supabase,yarn supabase,bunx supabase).
Every example in this guide is written as supabase <command>. Translate it to whichever form matches your install. See Install and run the CLI for the full setup.
Starting from a template
If you want a working project to explore rather than an empty one, supabase bootstrap scaffolds a starter application (Next.js, Flutter, and more) with schema, migrations, and config already wired up. It's an alternative entry point to supabase init when starting a new project from scratch.
The ./supabase#
After supabase init, your project contains a ./supabase directory. Here's what goes in it and what to commit:
| Path | Purpose | Commit? |
|---|---|---|
config.toml | Local stack configuration (ports, auth settings, etc.) | Yes |
migrations/ | Timestamped SQL migration files, applied in order | Yes |
seed.sql | Dev/test data, applied after migrations on start and db reset | Yes |
schemas/ | Declarative schema files (if using that approach) | Yes |
.temp/, .branches/ | CLI internal state | No |
The config.toml is safe to commit. It contains no secrets by default. If you add sensitive values (OAuth credentials, API keys), use the env() function to reference environment variables instead of hardcoding them. See Managing config and secrets.
Local vs. remote targets
Many database commands accept --local and --linked flags to choose what they act on. The defaults are not the same across commands: db diff and db reset default to --local, while db pull, db push, and db dump default to --linked. When in doubt, pass the flag explicitly.
Which diff engine you're on #
db diff, db pull, and the db schema declarative commands generate SQL with a diff engine. The CLI ships two. pg-delta is the default engine, and migra is the legacy engine.
The CLI uses the pg-delta diff engine unless config.toml opts out. If it contains an [experimental.pgdelta] section with enabled = false, your project uses the legacy migra engine. If the section is missing, or enabled is true or omitted, your project uses pg-delta. See Diff engines for how the two differ and how to roll back.
This guide describes pg-delta behavior and calls out where migra behaves differently. The db schema declarative commands require pg-delta and won't run on migra. For a side-by-side comparison and what to expect when an existing project upgrades, see Diff engines.
Move an existing project to local development#
You've built a project on the Supabase platform, with tables created via the Dashboard, SQL editor, or client libraries. Now you want a local dev setup with everything in version control.
Step 1: Initialize#
In your project root:
supabase initThis creates ./supabase/config.toml. If you already have a project directory with application code, run this at the root. The supabase/ directory will sit alongside your app code.
Step 2: Authenticate#
supabase loginOpens a browser to generate an access token. The token is stored locally and used for all subsequent CLI commands that interact with the platform.
Step 3: Link to your remote project#
supabase link --project-ref <project-id>Find your project ID in the Supabase Dashboard URL: https://supabase.com/dashboard/project/<project-id>.
This tells the CLI which remote project to connect to for db pull, db push, and other remote operations. You'll be prompted for the database password, which is the password set when you created the project.
Step 4: Pull the remote schema#
supabase db pullThis builds a shadow database from your local supabase/migrations directory (empty at this point), diffs your remote database against it, and saves the difference as a migration file:
supabase/migrations/<timestamp>_remote_schema.sqlOn this initial pull, the whole migration comes from that diff, which captures your remote schema as executable SQL. This migration is your baseline. All future changes build on top of it. db pull also offers to record this migration as already applied in the remote migration history (the supabase_migrations.schema_migrations table). Accept it (non-interactive runs accept automatically) so a later db push won't try to reapply it.
If a change crosses a transaction boundary, db pull may write more than one ordered migration file instead of a single file. Commit all of them. On the legacy migra engine, this initial pull seeds the migration with pg_dump and appends a diff of what the dump skips.
When connecting with --db-url, the connection pooler in session mode is the safe default. Direct connections (db.<project-ref>.supabase.co:5432) require IPv6 (or the IPv4 add-on), so on IPv4-only networks they fail outright.
Review the generated migration
The generated file can include statements you didn't expect. The engine excludes Supabase platform-managed schemas, roles, and a small set of platform extensions, but extensions you enable yourself (pg_net, pg_cron, pgcrypto, and others) are diffed like any other object. A DROP EXTENSION "pg_net"; appears when your local configuration or migrations enable the extension and the remote project doesn't have it. These statements apply silently on db reset and change your local schema, so read the file before committing it. See Cleaning up generated migrations for what to look for.
Customizations in the `auth` and `storage` schemas
Under the pg-delta engine, the diff excludes platform objects in Supabase-managed schemas such as auth and storage. It captures your customizations on top of them, such as triggers on managed tables, RLS policies on any table in auth, and RLS policies on storage.objects, storage.buckets, and realtime.messages. A trigger counts as yours only when its function lives outside the managed schemas (for example, a trigger on auth.users calling a function in public). A trigger whose function lives inside auth or storage is excluded, even if you created the function. The diff also skips other objects you create inside these schemas, such as your own functions or indexes.
Manage those objects with hand-written migrations. On later pulls, --schema limits the diff to the schemas you list, so leave it off (or include auth and storage) to keep capturing these customizations. On the legacy migra engine, the initial pull's pg_dump skips these schemas, but the diff pass that follows appends your triggers and RLS policies in them to the same migration. If an older CLI prints a message that auth and storage were excluded, run supabase db pull --schema auth,storage once.
Step 5: Create seed data#
You have two options:
Option A: Dump existing data from remote (then clean it up):
supabase db dump --data-only --linked > supabase/seed.sqlReview and clean up the dump before committing. Remove production user data, secrets, personal information, and anything sensitive. Keep only representative test data that a developer needs to work with the project.
Option B: Write seed data by hand (recommended for most projects):
Create supabase/seed.sql with INSERT statements that set up a useful local development state, such as a few test users and sample data. This is often better than dumping production data because you control exactly what's in it.
For more on organizing seed files, glob patterns, and generating realistic data, see Seeding your database.
Step 6: Verify#
supabase startsupabase db resetdb reset destroys the local database and recreates it from scratch: it applies all migrations in order, then runs seed.sql. If this succeeds, your setup is reproducible. Anyone who clones the repo can do the same.
Step 7: Commit#
git add supabase/git commit -m "add supabase local development setup"Your project now has a fully reproducible local development environment.
What about declarative schemas?
For an existing project, the pulled migration is already your schema baseline. You don't need to also create a schemas/ directory, because that would mean maintaining two representations of the same schema. If you want to adopt declarative schemas later, supabase db schema declarative generate bootstraps the supabase/schemas/ directory from your live database. See Declarative database schemas. For day-to-day changes, see The daily workflow.
Start a new project from scratch#
No remote project yet. You're building from scratch and want to do it right from the start.
Step 1: Initialize#
supabase initStep 2: Start the local stack#
supabase startOn first run, Docker images are pulled, which takes a few minutes. Subsequent starts are fast. Once running, the CLI outputs local service URLs and credentials, including the Studio URL for a local instance of the Dashboard. See Install and run the CLI for the full output and how to reach each service.
Step 3: Create your schema#
Two approaches, pick one:
Option A: Declarative schema (recommended for new projects)
Declare the state you want your database to be in as a file in supabase/schemas/, for example:
create table public.todos ( id bigint generated by default as identity primary key, created_at timestamptz default now() not null, title text not null, is_complete boolean default false not null, user_id uuid references auth.users (id) default auth.uid() not null);alter table public.todos enable row level security;create policy "Users can read their own todos" on public.todos for select using (auth.uid() = user_id);create policy "Users can create their own todos" on public.todos for insert with check (auth.uid() = user_id);Then generate a migration from it:
supabase db schema declarative sync -f initial-schemaThis compares your declared schema files against your (currently empty) migration history and writes the difference as a migration file in supabase/migrations/. The command then offers to apply the migration to your local database. Pass --apply or --no-apply to skip the prompt in scripts. The global --yes flag also applies it. Without one of those flags, a non-interactive run (CI, or an agent without a terminal) writes the file and silently skips the apply step.
The db schema declarative commands require pg-delta, which is the default engine (supabase init also writes [experimental.pgdelta] enabled = true into config.toml to make that explicit). For the full declarative workflow, including managing views and functions and known caveats, see Declarative database schemas.
Option B: Write the migration directly
supabase migration new initial-schemaThis creates an empty file at supabase/migrations/<timestamp>_initial-schema.sql. Write your SQL in it, then apply:
supabase db resetStep 4: Add seed data#
Create supabase/seed.sql:
-- Create a test user (Supabase Auth)-- Note: this is a placeholder row so seeded data has a user_id to reference.-- It has no password, so it can't be used to sign in. To create a-- login-capable user, use the Auth admin API or the local Studio.insert into auth.users (id, email, raw_user_meta_data)values ('d0e3c8f0-1234-5678-9abc-def012345678', 'test@example.com', '{}');-- Seed application datainsert into public.todos (title, user_id)values ('Buy groceries', 'd0e3c8f0-1234-5678-9abc-def012345678'), ('Write documentation', 'd0e3c8f0-1234-5678-9abc-def012345678');Step 5: Verify#
supabase db resetDrops everything, applies migrations, runs seed. If this passes, your project is reproducible.
Step 6: Commit#
git add supabase/git commit -m "add supabase local development setup"The daily workflow#
Both starting points converge here. You have a working ./supabase directory in your repo. Here's how day-to-day development works.
Making schema changes#
Which approach you use is a project-level decision, set when you first created your schema - not a per-change choice. It depends on whether you keep declarative files in supabase/schemas/. Pick the tab that matches your project.
These steps assume the pg-delta engine. On the legacy migra engine, generate the migration with supabase db diff instead of db schema declarative sync, and follow Declarative schemas on the legacy migra engine.
- Edit your schema file(s) in
supabase/schemas/(add a table, a column, a policy, etc.) - Generate a migration:
supabase db schema declarative sync -f add-due-date-to-todo - Review the generated migration file(s). See Cleaning up generated migrations
- Verify the full chain:
supabase db reset - Commit the schema file and the migration(s) together
db schema declarative sync compares your supabase/schemas/ files against your existing migrations. It does not read the live local database. Changes you make directly in Studio or via SQL are invisible to it, so it reports "No schema changes found" and silently drops them. Always edit the schema files, then sync.
Don't use db diff to generate migrations from declarative files. Under pg-delta, db diff never uses supabase/schemas/ as its baseline. The old [db.migrations].schema_paths setting no longer changes that, and the CLI warns when the setting lists any paths. Remove it from config.toml.
Generating types#
If your app uses the generated TypeScript types, regenerate them whenever your schema changes:
supabase gen types --lang typescript --local > database.types.tsUse --linked instead of --local to generate from your remote project. TypeScript is the default language; pass --lang go, --lang swift, or --lang python for others.
For working with the generated types (helper types, JSON inference, type-safe queries) and automating regeneration in CI, see Generating types.
Staying in sync with your team#
When someone else pushes new migrations:
git pullsupabase db resetdb reset replays all migrations from scratch, so you'll always match the current state of the repo.
Pushing to a remote project#
When you're ready to deploy your schema to a remote Supabase instance:
# Authenticate (if not already)supabase login# Link to the remote project (if not already)supabase link --project-ref <project-id># Preview what will be appliedsupabase db push --dry-run# Apply migrationssupabase db pushdb push applies only migrations that haven't been applied to the remote yet. It tracks this via the supabase_migrations.schema_migrations table created automatically on the remote database.
To also seed a fresh remote instance (dev/staging environments only):
supabase db push --include-seedNever use --include-seed on a production database. Seed data is for development and testing.
Resetting a remote dev or staging project#
If a dev or staging remote drifts or gets into a messy state, you can wipe it and rebuild it from your local migrations:
supabase db reset --linkedUnlike the default supabase db reset, which targets your local database, the --linked flag runs against the remote project you connected with supabase link: it drops the remote schema, then replays every local migration in order. Add --include-seed to reload seed data as well.
db reset --linked is destructive: it erases all data in the linked remote database. Only run it against throwaway dev or staging projects, and double-check which project you're linked to (supabase projects list shows the linked one) before running it. Never use it on production.
For multi-environment setups with CI/CD (feature branches, staging, production), see Managing Environments.
Key commands at a glance#
| Command | What it does |
|---|---|
supabase init | Creates ./supabase/config.toml |
supabase start | Starts the local stack, applies migrations + seed |
supabase stop | Stops the local stack (data persists until db reset) |
supabase db reset | Destroys local DB, applies all migrations + seed from scratch |
supabase db reset --linked | Destroys the linked remote DB and rebuilds it from local migrations (destructive, dev/staging only) |
supabase db diff -f <name> | Generates a migration by diffing a live database (local by default) against a shadow built from your migrations |
supabase db schema declarative sync | Diffs supabase/schemas/ against your migrations and writes the difference as new migration file(s) |
supabase db schema declarative generate | Exports a live database into declarative schema files under supabase/schemas/ |
supabase db pull | Pulls remote schema into a new local migration file |
supabase db pull --declarative | Updates supabase/schemas/ from the remote database instead of creating a migration. Not for migra opt-outs |
supabase db push | Applies pending local migrations to the remote database |
supabase db dump | Exports remote DB schema (or --data-only for data) via pg_dump |
supabase migration new <name> | Creates an empty migration file |
supabase migration list | Compares local migrations against remote migration history |
supabase gen types --lang typescript | Generates TypeScript types from your database schema |
supabase link --project-ref | Connects local project to a remote Supabase project |
supabase login | Authenticates with the Supabase platform |
For the full command reference and every flag, see the CLI reference.
Cleaning up generated migrations#
When supabase db diff or db schema declarative sync generates a migration, review it before committing.
What pg-delta output looks like#
Generated SQL uses uppercase keywords, wrapped at a maximum width of 180 characters. You can override this with [experimental.pgdelta] format_options in config.toml, or set format_options = "null" to emit raw statements with no formatting applied.
Most changes produce a single migration file. When a change crosses a transaction boundary (for example alter type ... add value followed by a check constraint that uses the new enum value, which can't run in the same transaction), the CLI may write one ordered migration file per unit instead. The extra files carry a numeric segment suffix, such as <timestamp>_add-status_1.sql and <timestamp+1s>_add-status_2.sql. Commit all of them.
A migration whose statements can't run inside a transaction starts with this directive on its first line:
-- pg-delta: transaction=falsedb reset, db push, and migration up honor it by running the file's statements without a wrapping transaction. Keep the line. The CLI detects create index concurrently on its own and runs it standalone even without the directive, but other statements that can't run in a transaction depend on it. The directive also changes what happens on failure. Without a wrapping transaction, a failed statement leaves the earlier statements in the file applied.
Deploys through the GitHub integration don't honor the directive and run every migration inside a transaction, so these migrations fail there. Not every split file carries it. alter type ... add value runs in its own transaction, so its file is separate but has no directive.
Extension statements#
CREATE EXTENSION IF NOT EXISTS ... or DROP EXTENSION ... might appear when your local and remote extension sets differ. Keep the statement if it reflects a change you want. Remove it if the extension is already handled by a previous migration or you don't want to change it. Decide deliberately, because a DROP EXTENSION applies silently on db reset.
Objects that extensions create and manage themselves, such as partitions maintained by pg_partman and queue tables created by pgmq, are recognized as extension-managed. The diff never emits raw create table or drop table statements for them. Instead it expresses changes through the extension's own API, such as select pgmq.drop_queue('q'); or a delete from partman.part_config row, and the CLI flags those statements as destructive. Review them as carefully as any other drop.
Coverage warnings#
pg-delta reports schema objects it doesn't track (such as casts, operators, and text search configurations) as warnings instead of silently dropping them. Add those objects through hand-written migrations. To turn these warnings into hard failures (useful in CI), pass --strict-coverage.
Grants and revoke patterns#
Both engines treat permissions as part of the schema state, so generated migrations can include grant statements you didn't write. New tables can come with explicit GRANT lines for the default roles, and a first diff against an existing database can emit long runs of REVOKE ALL followed by GRANT statements derived from default privileges. If you haven't changed permissions, these lines are safe to remove. Be consistent across your team about whether you keep or remove them.
Known limitations of db diff#
No diff engine captures everything. DML (INSERT, UPDATE, DELETE) is never tracked, so you must add data changes to the migration by hand. This includes storage buckets, which are rows in the storage.buckets table rather than schema objects. See the full list of caveats in the declarative schemas guide.
If a diff looks wrong, you can fall back to the legacy engine for a single run (db diff --use-migra, or db pull --diff-engine migra) to compare, or opt out entirely with enabled = false under [experimental.pgdelta]. See Diff engines for how engine selection works and what differs between the two.
Treat generated output as a draft, not a final migration. When in doubt, review the generated SQL and adjust it manually.
Troubleshooting#
db reset fails with a migration error
The output will show which migration file failed and the SQL error. Fix the migration file, then run db reset again.
db push says migrations are already applied
The remote database already has those migrations in its history. Run supabase migration list to compare local vs. remote state. If they're out of sync, use supabase migration repair to correct the remote history.
Schema drift: remote was changed outside of migrations
If someone modified the remote database directly (via Dashboard, SQL editor, etc.), run supabase db pull to capture those changes as a new migration file. Then supabase db reset locally to verify everything still works.
db pull prints "No schema changes found" and exits non-zero
Local and remote are already in sync, so there is nothing to pull. If you script db pull in CI, expect a non-zero exit code. An empty db diff exits 0 instead.
db diff warns that schema_paths is ignored
Under pg-delta, declarative files are never part of the db diff baseline, so [db.migrations].schema_paths has no effect on it. Generate migrations from declarative files with supabase db schema declarative sync instead.
The first pull after upgrading to pg-delta is unexpectedly large
This happens when your migration history was built from legacy migra diffs, which didn't track objects such as comments, domains, roles, and publication membership. The first db pull on pg-delta captures those objects in a one-time catch-up migration. Your database already has them, so accept the prompt to record the migration as applied and review the file like any other generated migration. If your baseline came from the legacy engine's pg_dump path instead, it already contains those objects, and the first pull reports "No schema changes found". See Upgrade an existing project to pg-delta for the full procedure.
A diff looks wrong or comes back empty unexpectedly
Set PGDELTA_DEBUG=1 and rerun the command. The CLI writes a debug bundle with the extracted snapshots, plan, and diagnostics. Plan bundles land under supabase/.temp/pgdelta/v2/debug/<id>/, and bundles from failed runs land under supabase/.temp/pgdelta/debug/<id>/. The bundle shows what the engine saw. Attach it when filing a CLI bug report.
Docker issues on supabase start
Ensure Docker is running and has at least 7 GB of RAM allocated. If containers fail health checks, try:
supabase stopsupabase startIf problems persist, supabase stop --no-backup for a clean restart (this removes local database data).