All posts

Supabase DB Seed: seed.sql, config.toml & auth.users

By Mikhail Shytsko, Founder at Seedfast · · Updated

Supabase 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 guide covers the full Supabase 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 — no production data, no seed scripts to maintain.

  • supabase db seed is not a Supabase CLI subcommand — seeding runs as a side effect of supabase db reset and supabase start, which execute the files declared under [db.seed] in supabase/config.toml
  • The default path is a single supabase/seed.sql, but the sql_paths list supports globs — splitting seed data into multiple files is idiomatic once the single-file version stops being readable
  • auth.users cannot be safely seeded with raw SQL inserts on a real Supabase project; use supabase.auth.admin.createUser() with the service role key, or auth.admin.generateLink() for invite-style flows
  • Preview branches re-apply seed.sql on creation, so the file is the branch's whole dataset — that works until the schema drifts from what the file was written against
  • Seedfast reads the branch's live schema on each run and generates FK-valid connected rows from 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 — pick by the job, not the tool. Reference rows your tests assert on by literal value (the admin@example.com account, country codes, feature flags) belong in a short seed.sql. Authenticated users go through supabase.auth.admin.createUser() with the service role key — raw SQL into auth.users breaks on deployed projects. Relational bulk past ~10 tables that has to survive weekly migrations is the part that quietly destroys seed.sql — that's where Seedfast reads the live schema on each run and regenerates valid rows.

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. Which means the answer to "how do I run my seed file" depends on which file, which environment, and which schema — and that's what the rest of this guide unpacks.

In practice, two commands trigger your seed files:

  • supabase start — seeds on the first start of a local stack
  • supabase db reset — drops the local database, re-applies migrations, 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 after migrations finish. The default glob points at a single supabase/seed.sql. You can replace it 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 — 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 — not 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/). The sql_paths entries are relative to the supabase/ directory, so './seed.sql' resolves to supabase/seed.sql. supabase init writes this block for you on a new project, so the only time you need to add it by hand is on a repo that was set up before the [db.seed] block was 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 is left in the repo as documentation rather than 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/. 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.

The practical consequence: seed.sql can reference any table, function, type, or extension created by migrations, but 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 — 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. The gotcha is tests: if the harness connects as anon or an authenticated user, RLS policies apply and rows may be invisible even though they exist. Seeding the data is one problem; writing policies that let the right role read it is a separate one.

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:

  • Directdb.[ref].supabase.co:5432 — IPv6-only unless you enable the IPv4 add-on, straight to Postgres
  • Session pooleraws-0-[region].pooler.supabase.com:5432 — PgBouncer in session mode, IPv4-compatible, keeps one Postgres connection per client session
  • Transaction pooleraws-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 that uses short-lived transactions runs fine through 6543; seed scripts do not.

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 resolves foreign keys, inserts in dependency order, 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: Neosync was acquired by Grow Therapy in September 2025, the repo was archived on August 30, 2025, and the hosted cloud is offline. The Neosync alternative guide walks through the migration — short version: Seedfast for generation, Greenmask for anonymization. Seedfast is the actively maintained schema-aware alternative for teams without an existing Snaplet or Neosync investment: it reads the live Supabase schema on every run and regenerates FK-valid rows, so there is no client to re-sync after a migration.

A reasonable question after two consecutive shutdowns: why would Seedfast survive when Snaplet and Neosync didn't? Different sustainability shape — Seedfast is a commercially supported product with public pricing and paying customers funding the roadmap, 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 there is no client to sync, no seed plan to hand-edit after a migration, and FK order is resolved from the schema rather than written into the seed.

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 inserts in topological order and handles self-referential tables (employees.manager_id → employees.id) — for circular FK chains, it fills nullable references in a second pass, and for constraints declared DEFERRABLE INITIALLY DEFERRED it issues SET CONSTRAINTS ALL DEFERRED inside the insert transaction so the whole batch commits atomically.

In our internal runs, Seedfast generates around a million FK-valid rows into a typical 20-table SaaS schema in roughly three and a half minutes — one command, no seed file to maintain.

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 rather than 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. 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: it queries the table, picks valid IDs, and uses 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, production. 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: 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 — it is 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). 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) — 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 — the GoTrue auth server looks it up on sign-in. The admin API creates that row automatically; the raw SQL path does not.

The rule most teams converge on: admin API for the users the app will see, seed.sql for 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.
  • aud and role — both 'authenticated'. The aud claim has to match GoTrue's JWT_AUD (default authenticated) or the issued JWT will be rejected on sign-in.
  • encrypted_passwordcrypt('plaintext', gen_salt('bf')) produces a bcrypt hash GoTrue can verify. gen_salt('bf') defaults to cost 6, which is fine for seed data; pass gen_salt('bf', 10) if you want production-grade cost.
  • email_confirmed_at — set to NOW() so the user can sign in immediately. Without it, GoTrue returns email not confirmed on 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 the email identity 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 — 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. As 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 identically — seedfast seed against the direct connection string runs the same command, scope, and result in both — which is 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 — the same mental model the local stack uses, in the cloud.

Three things to know:

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: it reads each new branch's live schema on every run and regenerates valid rows, where a TypeScript seeder in CI would require hand-maintaining code 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. Neon and Supabase branching are different mechanisms (CoW branches vs separate databases per branch), but the question of "when do I reseed versus inherit" is the same question.

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.

Aspectseed.sqlTypeScript seederSnaplet / supabase-community/seedSeedfast
SetupIn every Supabase project by defaultOne file, one driver installInstall, sync, write seed plansnpm install -g seedfast
What you maintainThe fileThe scriptThe seed plans + re-run sync on schema changeNothing — reads the live schema
FK orderManualManualAutomatic (from introspection)Automatic
Survives migrationsNo — hand edit on every schema changeNo — hand edit on every schema changeOnly after sync (client regen step)Yes — re-reads the live schema each run
Handles auth.usersLocal only; breaks on deployedWith admin API, yesNot for auth.users specificallyFocus is application tables; auth via admin API
Works on remoteManual (psql against direct URL)SameWorks if you point the client at the remoteSame — seedfast seed against the direct URL
CostFreeFree (your code)Free (OSS)30-day free trial, paid plans after
Sends schema off-machineNoNoNoYes — schema metadata goes to the Seedfast service for planning (what crosses the wire)
Good for reference/literal rowsExcellentExcellentOverkillNot the target
Good for relational bulkPainful past ~100 rows / ~10 tablesWorks, high code maintenanceWorks, high schema-sync maintenanceThe target use case

Pick based on the job. Reference and fixture rows live in seed.sql. Authenticated users use the admin API. Relational bulk that has to stay valid through migrations is the part that stops fitting into either — Seedfast handles that without a seed file to keep in sync. Try Seedfast on your Supabase schema — 30-day free trial, no credit card.

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.

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.

supabase db reset --linked exists and resets the remote database, but it drops user-created public-schema tables and re-runs migrations — destructive, and unsafe in CI against a shared staging environment. The common pattern for remote seeding is psql "$SUPABASE_DB_URL_DIRECT" -f seed.sql (or a TypeScript seeder) gated behind an environment check.

The session pooler (port 5432 on the pooler.supabase.com host) keeps one Postgres connection per client session, 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.

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.

No — it coexists. Reference and fixture rows that tests reference by literal value (admin accounts, feature flags, country codes) stay in a short seed.sql or seed.ts. Seedfast fills the relational bulk around them — orders, activity, posts, events — using the live schema, so the part that normally breaks on every migration is the part it takes off your hands.

Yes. Seedfast offers a 30-day free trial, with no credit card required to connect and run the first seed — small schemas typically fit within its limits. Point seedfast connect at your Supabase direct or session-pooler connection string; if the schema exceeds trial limits, the CLI surfaces which limit was hit and links to the Seedfast pricing page.

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.

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.

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.

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.

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.

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.

Set enabled = false under [db.seed] in supabase/config.toml. supabase db reset and supabase start then skip every file in sql_paths while still running migrations. Flip it back to true when you want the seed files to apply again — the next reset picks them up without other changes.

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. The order is fixed; there is no config option to reorder it. A migration cannot read rows inserted by seed.sql, but seed.sql can reference anything migrations create.

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"

No seed.sql edit. No seed.ts regen. The schema is the source of truth, and Seedfast reads it fresh on every run. Get started with Seedfast — 30-day free trial, no credit card.