Supabase DB Seed: seed.sql, config.toml & auth.users
By Mikhail Shytsko, Founder at Seedfast · · Updated
To seed a Supabase database, seeding runs through supabase/config.toml. The [db.seed] block lists the SQL files that supabase db reset and supabase start execute against the local database. There is no standalone supabase db seed subcommand. The default file is supabase/seed.sql, and sql_paths accepts globs for splitting seed data across multiple files. This Supabase seed database guide covers the full seeding surface: config.toml, seed.sql, auth.users, preview branches, and Seedfast, which reads your live Supabase schema and generates realistic, FK-valid relational data from a plain-English scope, with no production data involved and no seed scripts left behind to maintain.
supabase db seedis not a Supabase CLI subcommand. Seeding runs as a side effect ofsupabase db resetandsupabase start, which execute the files declared under[db.seed]insupabase/config.toml- The default path is a single
supabase/seed.sql, but thesql_pathslist supports globs. Splitting seed data into multiple files is idiomatic once the single-file version stops being readable auth.userscannot be safely seeded with raw SQL inserts on a real Supabase project; usesupabase.auth.admin.createUser()with the service role key, orauth.admin.generateLink()for invite-style flows- Preview branches re-apply
seed.sqlon creation, so the file is the branch's whole dataset. That works until the schema drifts from what the file was written against - Seedfast regenerates FK-valid connected rows for the branch's current schema on each run, driven by a plain-English scope
You opened the Supabase docs, saw the seed.sql example, thought "there must be a supabase db seed command that runs this", typed it, got unknown command, and closed the tab. That sequence is why this article exists. Supabase has a real seeding story; it just isn't a single subcommand. This guide covers what the actual workflow looks like, why auth.users and remote seeding are the two places it gets painful, and what to use when the seed file stops fitting your schema.
TL;DR: This guide walks through every practical way to seed a Supabase database: seed.sql, TypeScript seeders, the auth.users admin API, and Seedfast, with runnable SQL and TypeScript examples for each.
If you want the cross-database fundamentals first, how to seed a database covers the PostgreSQL version without the Supabase-specific surface. This article is specifically about the Supabase CLI, seed.sql, branching, and auth.users.
Type supabase db seed and the CLI returns unknown command. The supabase db namespace covers reset, push, pull, diff, dump, lint, query, and start, and there's a separate supabase seed buckets for storage, but seeding tables happens as a side effect of supabase db reset reading [db.seed] from config.toml. So the answer to "how do I run my seed file" turns on the file, the environment, and the schema involved, which is what the rest of this guide unpacks.
In practice, two commands trigger your seed files:
supabase startseeds on the first start of a local stacksupabase db resetdrops the local database, re-applies migrations, then runs the seed files
Both of them read [db.seed] from supabase/config.toml to figure out which files to run. Everything else in this article is about what goes into those files and where the default path breaks.
A new Supabase project gets this in supabase/config.toml when you run supabase init:
[db.seed]
enabled = true
sql_paths = ['./seed.sql']
The CLI runs those SQL files in order against the local database once migrations finish, with the default glob pointing at a single supabase/seed.sql that you can replace with a list, a glob, or both:
[db.seed]
enabled = true
sql_paths = [
'./seeds/00-reference.sql',
'./seeds/10-users.sql',
'./seeds/20-*.sql'
]
Files are executed in the order they appear in sql_paths, with globs expanded alphabetically. The "seed fails halfway and leaves the database half-populated" failure mode is real, because none of the files are wrapped in a transaction automatically. If you want all-or-nothing, wrap each file's contents in BEGIN; ... COMMIT;.
Via the CLI, seed files run after supabase db reset or the first supabase start. They do not run on subsequent starts (the stack already has data), and not automatically against remote projects. Preview branches re-apply them on branch creation (covered below).
The shortest supabase/config.toml that runs a seed.sql on supabase db reset is a two-line [db.seed] block with enabled = true and sql_paths = ['./seed.sql']. Everything else in the file is optional for seeding to work.
# supabase/config.toml — minimal block for seeding
[db]
port = 54322
major_version = 15
[db.seed]
enabled = true
sql_paths = ['./seed.sql']
The file lives at supabase/config.toml, sibling to supabase/seed.sql and supabase/migrations/, and its sql_paths entries are relative to the supabase/ directory, so './seed.sql' resolves to supabase/seed.sql. supabase init writes this block automatically on a new project; add it by hand only on a repo that predates the [db.seed] block becoming the default.
Set enabled = false under [db.seed] to run migrations only and skip every file in sql_paths on supabase db reset and supabase start. The migrations still run; only the seed step is skipped.
[db.seed]
enabled = false
sql_paths = ['./seed.sql']
Two common reasons to flip it off: CI jobs that assert on the migration result directly (no seed noise in the rows), and environments where the seed data comes from a different tool (a TypeScript seeder, seedfast seed, or a snapshot restore) and seed.sql stays in the repo as documentation, not a runtime artifact. Re-enabling is a one-line edit and the next reset picks the file back up.
Supabase runs migrations before seed files on supabase db reset and the first supabase start. Migrations execute in alphabetical filename order from supabase/migrations/, and seed files then run in the order listed in sql_paths, with globs expanded alphabetically. The order is fixed; there is no config option to reorder.
In practice, seed.sql can reference any table, function, type, or extension created by migrations, while a migration cannot reference data inserted by seed.sql. If a migration needs reference rows (a feature flag, a default role) to exist before a subsequent migration runs, those rows have to be inserted by the migration itself (usually with an INSERT ... ON CONFLICT DO NOTHING in the migration file), not by seed.sql. The db.migrations.schema_paths option in config.toml controls which schemas migrations apply to, not the order between migrations and seeds.
The simplest seed is one SQL file with INSERTs.
-- supabase/seed.sql
INSERT INTO public.teams (id, name) VALUES
(1, 'Engineering'),
(2, 'Design')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.posts (id, team_id, title, body) VALUES
(1, 1, 'Launch plan', 'First draft'),
(2, 2, 'Brand voice', 'Work in progress')
ON CONFLICT (id) DO NOTHING;
Run it:
supabase db reset
The reset drops the local database, re-runs every migration under supabase/migrations/, then executes the files in sql_paths. If a statement fails, the reset fails, and the error line in stderr tells you which file and which row.
Three things to get right the first time:
Idempotency. ON CONFLICT DO NOTHING on every INSERT with a primary key keeps reruns cheap. Without it, the second supabase db reset on the same day fails on the first duplicate-key error. For reference data that should reflect the latest values (feature flags, role definitions), use ON CONFLICT (...) DO UPDATE SET ... instead.
Row Level Security (RLS). Supabase enables RLS by default on tables you create through the dashboard. The seed file runs as the postgres superuser locally, so RLS does not block it, which is the right default. Tests are the gotcha, since a harness connecting as anon or an authenticated user hits RLS policies the same way, and rows can end up invisible even though they exist. Getting the rows into the table is only half the job, since writing policies that let the right role read them back is a separate task your tests will hit.
Foreign keys. Insert parents before children. The reset does not defer constraints automatically. For schemas where circular FKs exist, either declare the FK DEFERRABLE INITIALLY DEFERRED and wrap inserts in a transaction with SET CONSTRAINTS ALL DEFERRED, or insert a nullable row first and UPDATE the reference in a second pass.
This path scales to a few hundred rows across a few tables. At 20 tables with weekly migrations, every ALTER TABLE ... ADD COLUMN ... NOT NULL on main breaks seed.sql until someone hand-edits it. Method 4 below addresses this directly. Seed file maintenance covers the general lifecycle in full, and the Supabase version is identical except that preview branches make the breakage more visible because every PR hits it.
When the seed outgrows a single SQL file (you need generated emails, UUIDs, timestamps, or any logic more complex than literals), a TypeScript seed script against the Supabase connection string is the next step.
// scripts/seed.ts
import { Client } from "pg";
import { faker } from "@faker-js/faker";
const client = new Client({ connectionString: process.env.SUPABASE_DB_URL });
async function main() {
await client.connect();
await client.query(`TRUNCATE public.posts, public.teams RESTART IDENTITY CASCADE`);
const { rows: teams } = await client.query(
`INSERT INTO public.teams (name) VALUES ($1), ($2) RETURNING id`,
["Engineering", "Design"],
);
for (const team of teams) {
for (let i = 0; i < 5; i++) {
await client.query(`INSERT INTO public.posts (team_id, title, body) VALUES ($1, $2, $3)`, [
team.id,
faker.company.catchPhrase(),
faker.lorem.paragraph(),
]);
}
}
await client.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Run it with tsx scripts/seed.ts after supabase db reset (or after migrations on a remote project). The same pattern works with Prisma's seed.ts, Drizzle's scripts, or Kysely; the only thing that changes is the driver. For the Prisma and Drizzle specifics on a Postgres connection like Supabase's, how to seed a database has the ORM-by-ORM walkthrough.
Supabase gives every project three connection strings:
- Direct (
db.[ref].supabase.co:5432): IPv6-only unless you enable the IPv4 add-on, straight to Postgres - Session pooler (
aws-0-[region].pooler.supabase.com:5432): PgBouncer in session mode, IPv4-compatible, keeps one Postgres connection per client session - Transaction pooler (
aws-0-[region].pooler.supabase.com:6543): PgBouncer in transaction mode, returns the Postgres connection to the pool after every transaction
For seeding, use direct or session pooler. The transaction pooler (port 6543) discards prepared statements between transactions. Any driver that prepares statements (pg with named queries, Prisma, Drizzle with prepared mode) will fail mid-seed with prepared statement "s1" already exists or cached plan must not change result type. App code with short-lived transactions runs fine through 6543, but a seed script's longer-lived work is exactly what that port breaks.
Snaplet was a managed data platform until August 2024, when the company shut down and open-sourced its seed library as supabase-community/seed. The tool introspects your Postgres schema and generates a type-safe seed client:
import { createSeedClient } from "@snaplet/seed";
const seed = await createSeedClient();
await seed.users((x) =>
x(10, () => ({
posts: (x) => x(3),
})),
);
It generates connected, FK-valid data and produces deterministic output via the copycat library. The values are placeholder-style (names and emails look like IDs, not like real business data), and the client has to be re-generated (npx @snaplet/seed sync, same npm package name as before the move) every time the schema changes.
As of June 2026, the repo has had limited commit activity since mid-2024, and open community questions about its roadmap on Supabase Discord and Answer Overflow appear to sit without a maintainer response. The library still works on schemas it already supports and is a reasonable fit for teams with an existing Snaplet Seed investment and a schema that doesn't churn much. If you are migrating off Snaplet Seed, that guide walks through the move in detail; the full category comparison covers where it lands against other data seeding tools.
Supabase teams who went a different direction and adopted Neosync for their seeding hit a parallel outage, since Neosync was acquired by Grow Therapy in September 2025, the repo was archived on August 30, 2025, and the hosted cloud went offline. The Neosync alternative guide walks through the migration. In short, that split works out to Seedfast for generation and Greenmask for anonymization. For teams without an existing Snaplet or Neosync investment, Seedfast is the actively maintained schema-aware alternative, since it regenerates FK-valid rows from whatever the schema looks like today instead of relying on a client that needs re-syncing after each migration.
Two consecutive shutdowns raise a fair question about why Seedfast would hold up where Snaplet and Neosync didn't, and the answer comes down to a different sustainability shape. Seedfast is built as a commercially supported product with public pricing from the start, rather than a free-tier-only OSS project hoping for an acquisition or VC round. That's not a guarantee, but it is a different failure mode than the two tools that just exited.
Seedfast reads the live Supabase schema (tables, columns, constraints, FKs) and generates valid, connected rows from a plain-English scope. Seedfast regenerates its output on every run, so FK order comes from the schema itself instead of a seed plan someone has to hand-edit after each migration.
npm install -g seedfast
# or: brew install argon-it/tap/seedfast
seedfast connect
# Paste the Supabase direct or session-pooler connection string when prompted
# (the "Connection string" under Project Settings → Database)
seedfast seed --scope "small SaaS app: 3 orgs, 20 users, 100 posts with realistic activity"
When a migration adds ALTER TABLE posts ADD COLUMN published_at TIMESTAMPTZ NOT NULL, the next seedfast seed picks up the new column without a code change. Seedfast handles self-referential tables (employees.manager_id → employees.id) the same way, and where the schema permits it, circular FK chains come out valid too, with no hand-written constraint handling.
In our internal runs, a single seedfast seed run generates around a million FK-valid rows into a typical 20-table SaaS schema in roughly three and a half minutes, with no seed file left to maintain afterward.
Different scopes for different environments:
# Local development
seedfast seed --scope "2 orgs, 5 users, 10 posts"
# Preview branch for a PR
seedfast seed --scope "3 signed-up users, 5 draft posts, 2 published"
# Staging
seedfast seed --scope "50 orgs, 500 users, 5,000 posts, 3 months of activity"
Seedfast coexists with the seed.sql path instead of replacing it. The reference data your tests depend on literally (the admin@example.com account, a specific feature flag, country-code lookups) stays in a short seed.sql or a trimmed seed.ts, while Seedfast fills the relational bulk around those fixtures. The CI/CD database seeding guide covers the pipeline-side wiring (scope strings in GitHub Actions, --output json for programmatic checks, API keys) and applies to Supabase projects unchanged.
Most real Supabase schemas have an FK from public.profiles.user_id to auth.users.id. Seedfast does not write to the auth schema; that belongs to the admin API (covered in the next section). The working pattern is a two-step seed:
# Step 1: create the auth users via the admin API (scripts/seed-users.ts from below)
tsx scripts/seed-users.ts
# Step 2: Seedfast fills the application tables, picking up the existing auth.users.id values
# as parent rows for any FK in public.* that references auth.users
seedfast seed --scope "2 users already exist — fill their profiles, 10 posts each, with comments"
Seedfast reads the existing rows in auth.users the same way it reads any other parent table, querying it, picking valid IDs, and using them as FK targets in the child rows it generates. The scope string can refer to the existing users by role ("existing admin users", "already-onboarded users") to keep the generated data coherent.
A 30-day free trial covers the full first run end-to-end. Small schemas typically fit within its limits, and no credit card is required to start. Connect the CLI to your Supabase project; for what crosses the wire during a run, see data handling and privacy.
Seeding auth.users in Supabase requires the admin API on any deployed project, not raw SQL inserts. Raw INSERTs into auth.users work locally because the local stack is a full Postgres you have superuser access to. They break on a deployed project because auth.users has triggers, an encrypted_password column that expects a bcrypt hash with Supabase's specific cost factor, and a relationship with auth.identities that the Supabase client populates for you.
The safe paths:
Admin API via supabase-js with the service role key. Runs against any environment, local, staging, or production, and produces real users that can log in.
The service role key bypasses Row Level Security and grants full project access. Keep it in server-side environment variables only, and never place it in a NEXT_PUBLIC_* variable, commit it to .env in a repo, or import it into a client bundle. The snippet below is a Node-only script, never imported from React, Next.js pages, or Edge runtimes.
// scripts/seed-users.ts — Node-only. Never imported from client code.
import { createClient } from "@supabase/supabase-js";
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // server-side env var only
{ auth: { autoRefreshToken: false, persistSession: false } },
);
for (const u of [
{ email: "alice@example.com", password: "dev-password-1" },
{ email: "bob@example.com", password: "dev-password-2" },
]) {
const { data, error } = await admin.auth.admin.createUser({
email: u.email,
password: u.password,
email_confirm: true, // skip the confirmation email in seeds
});
if (error) throw error;
console.log("created", data.user?.id);
}
CLI token for invite-style flows. Call admin.generateLink({ type: 'invite', email }) and insert the link into the local email inbox (Inbucket at localhost:54324 on the local stack), which is useful for testing the accept-invite path.
SQL insert with the function Supabase uses internally. The minimum viable row is longer than it looks, and it will only produce a sign-in-capable user after a matching auth.identities row is added. On a deployed project, the same code creates a half-formed row that cannot sign in and cannot be cleanly re-created via the admin API (the email will already exist), so do not run it there.
-- LOCAL ONLY — DO NOT RUN against a deployed Supabase project.
-- Without a matching auth.identities row, the user cannot sign in with
-- email/password even locally. On a remote project, this creates a
-- broken state that is harder to undo than to prevent. Use
-- supabase.auth.admin.createUser() (above) for any deployed environment.
INSERT INTO auth.users (instance_id, id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at, raw_app_meta_data, raw_user_meta_data)
VALUES (
'00000000-0000-0000-0000-000000000000',
gen_random_uuid(),
'authenticated',
'authenticated',
'alice@example.com',
crypt('dev-password-1', gen_salt('bf')),
NOW(), NOW(), NOW(),
'{"provider":"email","providers":["email"]}',
'{}'
);
For email-login to work, a companion auth.identities row (provider_id, identity_data JSON, provider = 'email') is required, since the GoTrue auth server looks it up on sign-in. The admin API creates that row automatically; the raw SQL path does not.
Most teams converge on the same split, with the admin API handling the users the app will see while seed.sql covers the application tables those users own. Never put service role keys in seed.sql and never ship the admin-API seed script to a client bundle. Once auth.users is populated, run Seedfast on your Supabase project to fill the application tables that reference those users.
An auth.users insert in local seed.sql needs four things: a bcrypt hash from crypt('password', gen_salt('bf')) for encrypted_password, empty strings (not NULL) for the token columns, email_confirmed_at = NOW(), and a companion row in auth.identities for email/password sign-in. Do not run this against a deployed Supabase project; use the admin API there.
-- supabase/seed.sql — LOCAL DEVELOPMENT ONLY.
-- Never run this against a deployed Supabase project.
-- pgcrypto is preinstalled in the local Supabase stack; if it is not, run:
-- CREATE EXTENSION IF NOT EXISTS pgcrypto;
WITH new_user AS (
INSERT INTO auth.users (
instance_id,
id,
aud,
role,
email,
encrypted_password,
email_confirmed_at,
confirmation_token,
email_change,
email_change_token_new,
recovery_token,
raw_app_meta_data,
raw_user_meta_data,
created_at,
updated_at
) VALUES (
'00000000-0000-0000-0000-000000000000',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'authenticated',
'authenticated',
'alice@example.com',
crypt('dev-password-1', gen_salt('bf')),
NOW(),
'',
'',
'',
'',
'{"provider":"email","providers":["email"]}'::jsonb,
'{"full_name":"Alice Example"}'::jsonb,
NOW(),
NOW()
)
RETURNING id, email
)
INSERT INTO auth.identities (
id,
user_id,
provider,
provider_id,
identity_data,
last_sign_in_at,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
id,
'email',
id::text,
jsonb_build_object('sub', id::text, 'email', email),
NOW(),
NOW(),
NOW()
FROM new_user;
Field-by-field, the values that matter:
instance_id: always'00000000-0000-0000-0000-000000000000'for a single-tenant Supabase instance; the column is a hold-over from the multi-tenant GoTrue origin and the local stack expects this exact zero-UUID.audandrole: both'authenticated'. Theaudclaim has to match GoTrue'sJWT_AUD(defaultauthenticated) or the issued JWT will be rejected on sign-in.encrypted_password:crypt('plaintext', gen_salt('bf'))produces a bcrypt hash GoTrue can verify.gen_salt('bf')defaults to cost 6, which is fine for seed data; passgen_salt('bf', 10)if you want production-grade cost.email_confirmed_at: set toNOW()so the user can sign in immediately. Without it, GoTrue returnsemail not confirmedon the first login attempt.confirmation_token,email_change,email_change_token_new,recovery_token: empty strings, not NULL (see the next section for why).raw_app_meta_data: must include"provider":"email"and"providers":["email"]so the user record matches theemailidentity that GoTrue looks up on sign-in.
The companion auth.identities row is mandatory since the 2022 GoTrue change; without it, the user exists but email/password sign-in fails (covered in the section after the next one).
The converting NULL to string is unsupported error fires when a NULL sits in any of auth.users's seven token columns: confirmation_token, email_change, email_change_token_new, email_change_token_current, recovery_token, phone_change, or phone_change_token. Set every token column to an empty string ''. GoTrue's Go code (internal/models/user.go) scans these columns into a Go string, which the Postgres driver cannot do for NULL. The columns are technically nullable in the schema, but GoTrue treats them as NOT NULL-string in code.
-- If the existing rows already have NULL token columns, patch them:
UPDATE auth.users
SET
confirmation_token = COALESCE(confirmation_token, ''),
email_change = COALESCE(email_change, ''),
email_change_token_new = COALESCE(email_change_token_new, ''),
email_change_token_current = COALESCE(email_change_token_current, ''),
recovery_token = COALESCE(recovery_token, ''),
phone_change = COALESCE(phone_change, ''),
phone_change_token = COALESCE(phone_change_token, '')
WHERE
confirmation_token IS NULL
OR email_change IS NULL
OR email_change_token_new IS NULL
OR email_change_token_current IS NULL
OR recovery_token IS NULL
OR phone_change IS NULL
OR phone_change_token IS NULL;
The same fix belongs at the top of any seed.sql that creates auth users by hand, and any INSERT INTO auth.users should pass empty strings for those columns explicitly (as in the snippet above). The admin API (supabase.auth.admin.createUser()) does this for you, so the failure mode is specific to raw SQL inserts.
Since GoTrue migration 20231117164230_add_id_pkey_identities renamed the original id column to provider_id in November 2023, auth.identities.provider_id is NOT NULL. For an email identity, set provider_id to the user's id cast to text (<user uuid>::text). Skipping it returns null value in column "provider_id" of relation "identities" violates not-null constraint.
INSERT INTO auth.identities (
id, -- a fresh UUID for the identity row itself
user_id, -- the auth.users.id this identity belongs to
provider, -- 'email' for email/password identities
provider_id, -- the user's id as text — NOT NULL since November 2023
identity_data,
last_sign_in_at,
created_at,
updated_at
) VALUES (
gen_random_uuid(),
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'email',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- same uuid, cast to text in code or stored as text
jsonb_build_object(
'sub', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'email', 'alice@example.com'
),
NOW(),
NOW(),
NOW()
);
For OAuth identities the value is the provider's user id (the GitHub user id, the Google sub, etc.). For the email provider, it is the Supabase user's UUID as text, the same value used in identity_data.sub. The pattern matches what supabase.auth.admin.createUser() writes, which is why the admin API path stays compatible across GoTrue versions while hand-written inserts have to be updated when GoTrue's schema changes.
The CLI's seed files target the local stack. As of June 2026, a remote Supabase project does not pick them up automatically. supabase db reset --linked exists but drops user-created tables in the public schema and re-runs migrations against the remote, a destructive operation you likely do not want in a CI workflow against a shared staging database.
The working patterns are:
Run psql or a TypeScript seeder against the direct connection string. Copy the direct (or session-pooler) string from Project Settings → Database and run the seed as you would against any other Postgres. This is the pragmatic answer for staging and demo environments.
psql "$SUPABASE_DB_URL_DIRECT" -f supabase/seed.sql
Use the service role key for auth.users, exactly as covered above. The admin API works against the deployed project the same way it works locally.
Gate it. A destructive seed run against production is a single command away from deleting real user data. The safe pattern is an environment check at the top of the seed script (if (process.env.SUPABASE_URL.includes('production')) throw new Error('refuse');) and, if the seed is large, an explicit --force flag that has to be typed in.
The CLI-native story for remote seeding remains an open gap. Several community threads track it in the supabase/supabase GitHub discussions, but the feature is not in the CLI as of June 2026. Seedfast treats local and remote Supabase the same way, since seedfast seed against the direct connection string behaves identically in both. That's why teams seeding multiple Supabase environments on the same day standardize on Seedfast for remote seeding.
Supabase branches are full Postgres databases provisioned per Git branch. The preview branch re-applies your migrations and your seed.sql on creation, so a well-maintained seed.sql gives every preview branch a populated database. It's the same mental model the local stack uses, in the cloud.
Branching carries three practical wrinkles:
Seed files apply on branch creation. The sql_paths glob is evaluated, every matching file is executed, and the branch is ready. The time varies with the seed's size; a small reference seed finishes in seconds.
Branches see the seed state of the commit that created them. If you commit a seed.sql change on a feature branch, the preview branch for that PR gets the new seed. Branches opened before the change keep the old state until closed and re-created or until you push the new commit.
Large seeds or flaky seeds surface on every PR. The break-once-then-fix loop that shows up on a local stack once a week shows up on every PR in branching, because every PR creates a new branch and runs the file. Teams move dynamic data off seed.sql at this point. Seedfast keeps Supabase preview branches populated without a PR-time seeder edit, since each new branch gets rows regenerated from its own live schema on every run, not from code a TypeScript seeder in CI would need hand-maintained through every migration.
For the branch-level mechanics (copy-on-write, seed-parent-once, reseed-on-drift), Neon branching seed data covers the patterns in Neon-specific detail; how to seed a Neon database is the broader fundamentals guide. Even though Neon and Supabase branching are different mechanisms (CoW branches vs separate databases per branch), the question of "when do I reseed versus inherit" plays out the same way in both.
Once a preview branch needs relational bulk that outgrows seed.sql, reach for a generator that stays current with the branch's schema on each run (including the auth.users FKs your public.* tables reference) and regenerates FK-valid rows instead of replaying a static file. A schema-aware test data generator for Supabase does exactly that, while seed.sql stays right for literal reference rows. The connected data a real test scenario needs is what a Supabase seed data generator fills in.
The pain that pushes teams here is the same seed.sql-on-every-PR loop from the section above, where a column lands on main, the file goes stale, and the next preview branch fails until someone hand-edits it. A generator sidesteps that because there is no file tracking the schema at all; each run asks the database directly. Point Seedfast at the branch's direct or session-pooler connection string and scope the run to what the PR actually exercises:
# Against a Supabase preview branch's connection string
seedfast seed --scope "3 signed-up users, 5 draft posts, 2 published, with comments"
Every Seedfast run against that branch picks up whatever migration shipped in the same PR, without a code change, because there's no separate file that can fall out of sync with the schema. The reference data your tests assert on by literal value still belongs in seed.sql; the generator fills the relational bulk around it. The free alternative most people try first, pasting the schema into ChatGPT or Claude and asking for INSERT statements, works for a handful of rows and then drifts past the foreign-key graph once the schema has more than a few related tables. For the branch-mechanics contrast on Neon's copy-on-write model, see how to seed a Neon database; for the broader Postgres tool decision, the best Postgres test data generator guide compares the options, and Seedfast pricing is flat with no per-token metering.
You are seeding through the transaction pooler (port 6543). Switch the connection string to the direct (db.<ref>.supabase.co:5432) or session-pooler port (5432 on the pooler host).
You are trying to INSERT INTO auth.users with a non-superuser role. Locally, use the postgres role. On a deployed project, use the Admin API with the service role key; the schema is owned by the supabase_auth_admin role and ordinary users cannot write to it directly.
The seed is not idempotent. Add ON CONFLICT (email) DO NOTHING or ON CONFLICT (email) DO UPDATE SET ... to every INSERT that hits a unique index, or run supabase db reset which truncates before applying the seed.
The seed ran as postgres and the rows exist, but the test harness connects as anon or authenticated and the RLS policies are filtering them out. Check that the inserted user_id/team_id columns match a JWT sub your tests authenticate with, or run the app-side reads with the service role key in CI only.
The migration runs before the auth schema is ready. In local branches, this can happen if a migration uses a cross-schema reference and the extension load order is wrong. In CI, it usually means migrations are running against a plain Postgres container rather than the Supabase stack. Run supabase start before applying migrations so auth, storage, and friends exist.
A schema change invalidated a prepared statement cached in the pooler. Switch to the direct port for the seed, run migrations before seeding, and if the error repeats, reconnect the driver to drop the cached plans.
| Aspect | seed.sql | TypeScript seeder | Snaplet / supabase-community/seed | Seedfast |
|---|---|---|---|---|
| Setup | In every Supabase project by default | One file, one driver install | Install, sync, write seed plans | npm install -g seedfast |
| What you maintain | The file | The script | The seed plans + re-run sync on schema change | Nothing; it reads the live schema |
| FK order | Manual | Manual | Automatic (from introspection) | Automatic |
| Survives migrations | No; hand edit on every schema change | No; hand edit on every schema change | Only after sync (client regen step) | Yes; re-reads the live schema each run |
Handles auth.users | Local only; breaks on deployed | With admin API, yes | Not for auth.users specifically | Focus is application tables; auth via admin API |
| Works on remote | Manual (psql against direct URL) | Same | Works if you point the client at the remote | Same; seedfast seed against the direct URL |
| Cost | Free | Free (your code) | Free (OSS) | 30-day free trial, paid plans after |
| Sends schema off-machine | No | No | No | Yes; schema metadata goes to the Seedfast service for planning (what crosses the wire) |
| Good for reference/literal rows | Excellent | Excellent | Overkill | Not the target |
| Good for relational bulk | Painful past ~100 rows / ~10 tables | Works, high code maintenance | Works, high schema-sync maintenance | The target use case |
The table above breaks down by job: fixture rows suit seed.sql, authenticated users go through the admin API, and the relational bulk that outgrows both is what Seedfast handles without a seed file to keep in sync. Try Seedfast on your Supabase schema, free for the first 30 days with no credit card needed to start.
How does Supabase seed a database automatically?
On supabase db reset and on the first supabase start, the CLI reads the sql_paths list from supabase/config.toml under [db.seed], expands any globs, and runs the files in order against the local database after migrations finish. The default list is ['./seed.sql']; you can replace it with multiple paths to split a large seed across files.
How do I seed auth.users in Supabase?
auth.users in Supabase?Call supabase.auth.admin.createUser({ email, password, email_confirm: true }) with the service role key. That works locally and on deployed projects, produces users that can sign in, and populates the companion auth.identities row that GoTrue requires for email/password login. Raw INSERTs into auth.users skip that row, so the user cannot sign in even locally.
Can I run supabase db reset against a remote project?
supabase db reset against a remote project?supabase db reset --linked technically works, but it wipes every user-created table in the public schema before replaying migrations, so treat it as a last resort, not something you'd wire into a CI step against shared staging data. Day-to-day remote seeding instead runs through psql "$SUPABASE_DB_URL_DIRECT" -f seed.sql (or a TypeScript seeder), gated behind an environment check.
What's the difference between the session pooler and the transaction pooler for seeding?
The session pooler (port 5432 on the pooler.supabase.com host) pins your connection to one Postgres session for as long as the client stays connected, so prepared statements and session-level SET statements survive. The transaction pooler (port 6543) returns the connection after every transaction, which breaks prepared statements mid-seed. Use session pooler or direct for seeds; transaction pooler is for short-lived application queries.
How do I seed more than one SQL file?
Replace sql_paths = ['./seed.sql'] with a list or a glob:
[db.seed]
enabled = true
sql_paths = ['./seeds/00-reference.sql', './seeds/10-*.sql']
Files run in listed order; globs expand alphabetically. Wrap each file's contents in BEGIN; ... COMMIT; if you want all-or-nothing semantics, because the CLI does not wrap the whole sequence in one transaction.
Does Seedfast replace seed.sql?
seed.sql?No. The two cover different jobs, so Seedfast runs alongside seed.sql instead of replacing it. Fixtures a test asserts on by literal value stay in the file, while Seedfast handles the connected volume around them (orders, activity, posts, events), regenerated to match the current schema, which is exactly the part that used to break on every migration.
Is Seedfast free to try on a Supabase project?
Yes. The first 30 days are free, with no credit card needed to connect and run an initial seed, and most small schemas clear the trial's limits without hitting a wall. Point seedfast connect at your Supabase direct or session-pooler connection string; if a larger schema does cross a limit, the CLI names which one and links to the Seedfast pricing page.
How do I create an auth user in seed.sql for local development?
Insert into auth.users with encrypted_password = crypt('your-password', gen_salt('bf')), email_confirmed_at = NOW(), aud and role both 'authenticated', and empty strings (not NULL) for the token columns. Then insert a companion auth.identities row with provider = 'email' and provider_id = <user-uuid>::text. Working SQL is in the section above. Local development only; use supabase.auth.admin.createUser() for deployed projects.
What value goes in encrypted_password when seeding auth.users locally?
A bcrypt hash produced by crypt('plaintext-password', gen_salt('bf')) from the pgcrypto extension. GoTrue verifies the hash on sign-in using bcrypt with the cost factor encoded in the hash itself. The default gen_salt('bf') cost of 6 works fine for seeds; pass gen_salt('bf', 10) for production-grade cost. pgcrypto is preinstalled in the local Supabase stack; if it is missing, run CREATE EXTENSION IF NOT EXISTS pgcrypto; at the top of seed.sql.
Why does my seed fail with "converting NULL to string is unsupported"?
The error comes from GoTrue's Go code scanning an auth.users token column into a string field, and the column contains SQL NULL. Set confirmation_token, email_change, email_change_token_new, email_change_token_current, recovery_token, phone_change, and phone_change_token to empty strings ('') in your INSERT, or run the UPDATE ... SET col = COALESCE(col, '') patch in the "Fix converting NULL to string is unsupported" section above.
Why does my seed fail with "null value in column provider_id violates not-null constraint"?
Since GoTrue migration 20231117164230_add_id_pkey_identities (November 2023), auth.identities.provider_id is NOT NULL. For an email identity, set provider_id to the user's UUID cast to text (<user uuid>::text); for an OAuth identity, set it to the upstream provider's user id. The admin API writes this for you; raw SQL inserts have to include the column explicitly.
Where does sql_paths in supabase config.toml point?
sql_paths is the array under [db.seed] in supabase/config.toml that lists the SQL files supabase db reset and supabase start execute against the local database after migrations. Paths are relative to the supabase/ directory and support globs. The default value is ['./seed.sql'], which resolves to supabase/seed.sql.
What's the minimal config.toml for local development with seed.sql?
A two-line [db.seed] block: enabled = true and sql_paths = ['./seed.sql'], placed at supabase/config.toml. supabase init writes this for you. See the "Minimal config.toml for local development with seed.sql" section above for the full minimal file with the matching [db] block.
How do I disable seeding with db.seed.enabled = false?
Set enabled = false under [db.seed] in supabase/config.toml; migrations still run, but every file listed in sql_paths gets skipped on supabase db reset and supabase start. Flip it back to true whenever you want the seed step to resume, and the next reset picks the files back up with no other changes needed.
Do migrations run before seeds in Supabase?
Yes. On supabase db reset and the first supabase start, migrations run first (every file under supabase/migrations/ in alphabetical order by filename), and then the seed files run in the order they appear in sql_paths, with globs expanded alphabetically. That ordering is fixed, and Supabase doesn't expose a config option to change it. A migration cannot read rows inserted by seed.sql, but seed.sql can reference anything migrations create.
What's the best test data generator for Supabase?
It depends on what you're seeding, since literal reference rows are enough for seed.sql alone. For relational bulk that has to stay valid through migrations, you want a schema-aware generator. The two realistic options are supabase-community/seed (the open-sourced Snaplet Seed, which works on schemas it supports but has had limited maintenance since mid-2024 and needs a sync step on every schema change) and Seedfast (regenerates against the live Supabase schema on each run, with no client that needs re-syncing). Pick the one whose maintenance shape matches how often your schema churns.
How do I generate seed data for a Supabase preview branch?
Run seedfast seed against the branch's direct or session-pooler connection string with a --scope describing the rows you need. Every run regenerates against the branch's current schema, keeps foreign key references valid, and uses any existing auth.users rows as FK parents, so a migration shipped in the same PR is picked up without editing a seed file. Keep literal fixtures in seed.sql; let the generator fill the connected bulk.
If the part that hurts is seed.sql breaking every migration, start here:
npm install -g seedfast
seedfast connect # paste the direct or session-pooler connection string
seedfast seed --scope "small SaaS app: 3 orgs, 20 users, 100 posts with activity"
There's no seed.sql edit to make and no seed.ts to regenerate afterward, since the schema itself stays the single source of truth for every subsequent run. Get started with Seedfast, where the 30-day trial runs without a credit card.
- Database seeding methods and best practices: the conceptual pillar covering reference vs test data, idempotency, and when seed files stop scaling
- Database seeder tools compared: quick reference for picking a seeder, with per-ORM command syntax
- How to seed a database: PostgreSQL practical guide: the framework-agnostic version with Prisma, Drizzle, TypeORM, and raw
pg - PostgreSQL Test Data: A Syntax Cookbook: the raw SQL primitives (
generate_series,psql \copy,pg_dumpcarving) for filling tables before you reach for a generator - How to seed a Neon database: the Neon-specific sibling to this article, covering pooled vs direct, branch inheritance, and the three seeding methods
- Neon branching seed data: the branch-level patterns (seed-parent-once, drift-reseed, schema-only rescue) for Neon's copy-on-write model
- Seed file maintenance: why static
seed.sqldrifts from the schema and what the alternatives look like - Data seeding tools compared: the full comparison of Faker, ORM seeders, enterprise anonymization, and schema-aware generators
- Database seeding in CI/CD: pipeline wiring for Supabase, Neon, and any Postgres
- Get started with Seedfast: connect to your Supabase project and run the first schema-aware seed
Seedfast is not affiliated with, endorsed by, or sponsored by the products compared here. All product names, logos, and brands are the property of their respective owners and are used for identification purposes only. Comparisons reflect publicly available information as of the date shown.
Snaplet, Neosync, Greenmask are trademarks of their respective owners.