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.
This guide assumes one running local project. If you work on several apps or git worktrees at once and need a local project for each, see Running multiple local projects.
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
Database commands act on your local stack by default. Pass --remote to act on your linked project, --remote=<project-ref> for a specific project, or --remote=<postgres-url> for a database at a connection string. Commands that only work against a hosted project, such as functions deploy, secrets set, and config push, default to the linked project.
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.
pg-delta is the default diff engine. Your project uses the legacy migra engine only if config.toml contains an [experimental.pgdelta] section with enabled = false, or SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=false is set in your shell or a project .env file. See Diff engines for how the two differ and how to switch.
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 a switching procedure, 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 project --remote refers to for db pull, db push, and other commands that reach your hosted project. 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 pull --remoteThis 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. Before it writes the file, db pull asks to record this migration as already applied in the remote migration history (the supabase_migrations.schema_migrations table), so a later db push won't try to reapply it. Declining writes nothing. A non-interactive run needs --yes.
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 --remote=<postgres-url> for db pull or db diff, prefer the direct connection (db.<project-ref>.supabase.co:5432) so pg-delta can read the full catalog. Direct connections require IPv6 or the IPv4 add-on, so on an IPv4-only network use the session pooler instead. Don't use the transaction pooler for these commands. For db dump and psql, the session pooler is the safe default.
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 pg-delta, omit --schema on later pulls. The flag only narrows the diff further and can't add managed objects back, so listing auth alone would drop a trigger whose function lives in public. 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 --remote 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 --remote > 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 (or the directory set by experimental.pgdelta.declarative_schema_path) 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 diff engine. 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 > database.types.tsAdd --remote 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 --remote# Apply migrationssupabase db push --remotedb 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-seed --remoteNever 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 --remoteUnlike the default supabase db reset, which targets your local database, the --remote flag runs against the remote project you connected with supabase link: it drops the remote schema, replays every local migration in order, then runs your seed files. Pass --no-seed to skip seeding.
db reset --remote 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 --remote | Destroys the linked remote DB and rebuilds it from local migrations + seed (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 --remote | Pulls remote schema into a new local migration file |
supabase db pull --declarative --remote | Updates supabase/schemas/ from the remote database instead of creating a migration. pg-delta projects only |
supabase db push --remote | Applies pending local migrations to the remote database |
supabase db dump --remote | Exports remote DB schema (or --data-only for data) via pg_dump |
supabase migration new <name> | Creates an empty migration file |
supabase migration list --remote | 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. These statements can also come from default-privilege differences between the two databases, so before removing them, check that the roles already hold the intended privileges on the target database. If they do and you haven't changed permissions, the 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 --remote to compare local vs. remote state. If they're out of sync, use supabase migration repair --remote 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 --remote 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 on 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 Switch 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).
supabase start fails because a port is already allocated
Another local Supabase project is using the default ports. Stop it with supabase stop from its directory. To run both projects at the same time, turn on the experimental supabase stack commands and remove the fixed ports from each project's config.toml. See Running multiple local projects.