Skip to content
Local Development

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.

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.

The ./supabase#

After supabase init, your project contains a ./supabase directory. Here's what goes in it and what to commit:

PathPurposeCommit?
config.tomlLocal stack configuration (ports, auth settings, etc.)Yes
migrations/Timestamped SQL migration files, applied in orderYes
seed.sqlDev/test data, applied after migrations on start and db resetYes
schemas/Declarative schema files (if using that approach)Yes
.temp/, .branches/CLI internal stateNo

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.

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 init

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

Opens a browser to generate an access token. The token is stored locally and used for all subsequent CLI commands that interact with the platform.

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 pull

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

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

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

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 start
supabase db reset

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

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 init

Step 2: Start the local stack#

supabase start

On 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-schema

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

This creates an empty file at supabase/migrations/<timestamp>_initial-schema.sql. Write your SQL in it, then apply:

supabase db reset

Step 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 data
insert 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 reset

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

  1. Edit your schema file(s) in supabase/schemas/ (add a table, a column, a policy, etc.)
  2. Generate a migration: supabase db schema declarative sync -f add-due-date-to-todo
  3. Review the generated migration file(s). See Cleaning up generated migrations
  4. Verify the full chain: supabase db reset
  5. Commit the schema file and the migration(s) together

Generating types#

If your app uses the generated TypeScript types, regenerate them whenever your schema changes:

supabase gen types --lang typescript --local > database.types.ts

Use --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 pull
supabase db reset

db 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 applied
supabase db push --dry-run
# Apply migrations
supabase db push

db 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

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 --linked

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

For multi-environment setups with CI/CD (feature branches, staging, production), see Managing Environments.

Key commands at a glance#

CommandWhat it does
supabase initCreates ./supabase/config.toml
supabase startStarts the local stack, applies migrations + seed
supabase stopStops the local stack (data persists until db reset)
supabase db resetDestroys local DB, applies all migrations + seed from scratch
supabase db reset --linkedDestroys 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 syncDiffs supabase/schemas/ against your migrations and writes the difference as new migration file(s)
supabase db schema declarative generateExports a live database into declarative schema files under supabase/schemas/
supabase db pullPulls remote schema into a new local migration file
supabase db pull --declarativeUpdates supabase/schemas/ from the remote database instead of creating a migration. Not for migra opt-outs
supabase db pushApplies pending local migrations to the remote database
supabase db dumpExports remote DB schema (or --data-only for data) via pg_dump
supabase migration new <name>Creates an empty migration file
supabase migration listCompares local migrations against remote migration history
supabase gen types --lang typescriptGenerates TypeScript types from your database schema
supabase link --project-refConnects local project to a remote Supabase project
supabase loginAuthenticates 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=false

db 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 stop
supabase start

If problems persist, supabase stop --no-backup for a clean restart (this removes local database data).