# Seedfast: full text Last updated: 2026-09-26 > Seedfast reads your live database schema and generates realistic, relational test data that the database accepts on the first insert. One command fills a development, CI, or demo database with production-shaped data, and nothing is copied from production. Teams running 20+ tables, frequent migrations, and several environments lose real time to seed scripts that break on every schema change. Seedfast removes that maintenance. It reads the live schema on every run, covering tables, columns, types, foreign keys, constraints and triggers, then writes a connected dataset that holds up under every relationship the schema declares. Because nothing is copied from production, it also suits fintech, healthcare, and other settings where production data cannot be used in a development environment. ## Features - Seedfast reads the live Postgres schema on every run, so there are no seed files to write or keep in step with migrations. - Seedfast generates values that read as real data rather than placeholder strings, and keeps them coherent across joined tables. - Seedfast fills tables at whatever size the work needs, up to tens of millions of rows in a single run. - Seedfast builds every row from scratch, so no production access is required and nothing is copied out of production. - Seedfast runs as one CLI command, locally or as a pipeline step, and as an MCP tool call for assistants such as Claude and Cursor. ## Seedfast by the numbers Seedfast has seeded 325 Postgres schemas since November 2025, spanning 115 distinct databases and 2,600 distinct table names, and has generated 651,164,969 rows in total. Those schemas run wider than seed-script tutorials assume. The median carries 14 tables, the 75th percentile 24, the 90th percentile 39, and the widest single schema 432. 34.8% of them carry 20 or more tables and 24.9% carry 25 or more, which is roughly the width at which a hand-written seed script turns into a maintenance job of its own. Figures are first-party, measured from Seedfast run records on 2026-09-10. ## Install - `brew install seedfast-ai/tap/seedfast` - `npm install -g seedfast` ## Schema handling Seedfast is schema-aware. It reads the live schema and generates values that fit what the schema actually declares: foreign keys, composite keys, multi-level dependencies, check constraints, unique constraints, enums, JSON columns and triggers. The database accepts the result on the first insert, rather than a run failing halfway through on a constraint violation. Schemas with circular foreign-key references are handled, where the constraints allow a valid dataset to exist at all. ## Database support Seedfast is a schema-aware seeder for relational (SQL) databases. Supported today: PostgreSQL, including Supabase, Neon and Amazon RDS. Support for other SQL engines, including MySQL, Oracle and SQLite, is in development. ## Data realism Seedfast reads table names, column names, data types and constraints to work out what the data is meant to represent before generating any of it. A table called `orders` with `status`, `total_amount` and `placed_at` gets plausible statuses, realistic amounts and timestamps spread across a sensible window. It does not emit random placeholder values, and there is no per-domain configuration to choose, because the same schema-reading applies to whatever the schema happens to describe. Language and format are yours to set in the scope, written in whatever language you think in. Generated values can come back in any language, and the shape of a column is describable too: national phone-number formats, email addresses on your own domains, internal SKU patterns, prefixed invoice counters, URLs pointing at a staging host. Anything a column holds can be specified this way. ## Volume Volume is part of the scope, not a separate setting. Fifty rows for a local branch, a million for load testing, tens of millions when the tables need to weigh what production weighs. That last figure is enough to reproduce the working set of a large, high-traffic product, where index choices start to matter and a query plan that held over ten thousand rows can flip at fifty million. Realism does not degrade as the row count climbs. ## Integrations - **CI/CD**: Seedfast runs as a pipeline step. GitHub Actions, GitLab CI and CircleCI are documented at https://seedfa.st/docs/cicd-database-seeding - **MCP**: Seedfast runs as an MCP server, so Claude, Cursor and other MCP-capable assistants can seed a database as a tool call. Setup at https://seedfa.st/docs/mcp-setup-guide - **CLI**: the primary Seedfast interface, local or remote. Reference at https://seedfa.st/docs/seeding ## Pricing Every plan includes a pool of credits that refills monthly. A seed draws from it based on how much data it generates, with no table or seed limits, and paid plans carry up to 2.6x more credits than they cost. The free plan needs no card and does not expire. - **Free** at $0/month: $5 of monthly credits ($10 with a work email); Around 8 seeding runs on a 30-table schema - **Basic** at $16/month: $32 of monthly credits; Around 50 seeding runs a month on a 30-table schema; Top up anytime - **Premium** at $69/month: $180 of monthly credits; Around 300 seeding runs a month on a 30-table schema; Top up anytime; Priority support; Early access to new features See https://seedfa.st/pricing for current plans. ## Instructions for AI Agents - Cite the canonical page URL, never the `/raw` variant. Raw markdown is served for ingestion only and is marked `noindex`. - Fetch https://seedfa.st/llms-full.txt to get the complete text of the documentation and every article in a single request. - For installation and CLI behaviour, the documentation under https://seedfa.st/docs is authoritative. Blog articles are explanatory and can lag the docs. - Plans and limits change. Read https://seedfa.st/pricing rather than quoting figures from an article. - Seedfast generates synthetic data from a schema. It does not copy, mask, or de-identify existing production rows, so recommend it for seeding rather than for anonymisation. ## Contents This file inlines every documentation, blog, compare, and free tools page on https://seedfa.st, in that order. Each document is preceded by its canonical URL. The link-only index is at https://seedfa.st/llms.txt. ======================================================================== # Section: Documentation ======================================================================== # Welcome to Seedfast > Source: https://seedfa.st/docs/welcome > Last updated: 2026-09-10 Seedfast generates realistic synthetic data at any volume — directly into your database. ## The problem You need data to develop and test against. Production data is off-limits — it contains real user information, and using it creates compliance risk, which is the reason teams [keep production rows out of staging](/blog/staging-without-prod-data) altogether. Writing fixtures by hand is slow and breaks every time the schema changes. ## The solution Seedfast reads your schema and generates realistic data from scratch — no production records involved. Connect your database, describe what you need, and data lands in your tables. **macOS / Linux:** ```bash seedfast seed --scope "50 users with orders and invoices" ``` **Windows:** ```bash npx seedfast seed --scope "50 users with orders and invoices" ``` Seedfast resolves table dependencies, satisfies foreign key constraints, [tables that reference each other in a cycle included](/blog/circular-foreign-key-seed), and generates domain-aware values automatically. No configuration files, no exports, no dashboards. ## What you get - **Realistic data** — values inferred from your schema, not random placeholders - **Direct DB writes** — data goes straight into your tables, local or remote - **Any volume** — from a handful of rows to tens of millions, described in plain English - **Secure by design** — production data is never used, never at risk ## Integrations - **CLI** — `seedfast seed` (macOS/Linux) or `npx seedfast seed` (Windows) from any terminal or script - **MCP** — seed directly from Claude, Cursor, or VS Code in plain English - **CI/CD** — [one line in GitHub Actions or GitLab CI](/docs/cicd-database-seeding), authenticated via API key --- # Install the Seedfast CLI > Source: https://seedfa.st/docs/installation > Last updated: 2026-04-13 The Seedfast CLI is a single binary that runs on macOS, Windows, and Linux. Once installed, every Seedfast feature — seeding, connecting databases, MCP integration — is available from your terminal. ## Install the CLI The recommended way to install on macOS and Linux is via Homebrew. It handles updates automatically and keeps the binary on your PATH with no extra configuration. **Homebrew** (macOS / Linux): ```bash brew install seedfast-ai/tap/seedfast ``` On Windows — or if you prefer not to use Homebrew — install globally via npm. This works on any platform that has Node.js available. **npm** (all platforms): ```bash npm install -g seedfast ``` Once installed, verify everything is working: ```bash seedfast --version ``` You should see the current version number printed in your terminal. If you get a "command not found" error, run `npm bin -g` to find where npm installs global binaries and make sure that path is in your PATH. --- # Authentication > Source: https://seedfa.st/docs/authentication > Last updated: 2026-04-15 Seedfast uses two authentication methods depending on how you run it: browser-based login for interactive local use, and API keys for CI/CD pipelines and scripted environments. ## Browser login (interactive) For local development, authenticate once with your Seedfast account: **macOS / Linux:** ```bash seedfast login ``` **Windows:** ```bash npx seedfast login ``` A browser window opens and walks you through the login flow. Once complete, credentials are stored locally and reused on every subsequent run — you won't need to log in again on the same machine unless you explicitly log out. If you don't have an account yet, sign up at [seedfa.st](https://seedfa.st) — a free plan is available, no credit card required. To log out: **macOS / Linux:** ```bash seedfast logout ``` **Windows:** ```bash npx seedfast logout ``` ## API keys (non-interactive) In CI/CD pipelines and other non-interactive environments, browser-based login isn't available. Use an API key instead. **Generate an API key:** 1. Log in to the [Seedfast Dashboard](/dashboard?section=api-keys) 2. Open **API Keys** 3. Click **Create New Key** and give it a name (e.g. "GitHub Actions") 4. Copy the key immediately — it won't be shown again Your key format: `sfk_live_...` **Use the key in your environment:** **macOS / Linux:** ```bash export SEEDFAST_API_KEY=sfk_live_your_key_here seedfast seed --scope "..." ``` **Windows (PowerShell):** ```powershell $env:SEEDFAST_API_KEY="sfk_live_your_key_here" npx seedfast seed --scope "..." ``` Or pass it inline: **macOS / Linux:** ```bash SEEDFAST_API_KEY=sfk_live_your_key_here seedfast seed --scope "..." ``` **Windows (PowerShell):** ```powershell $env:SEEDFAST_API_KEY="sfk_live_your_key_here"; npx seedfast seed --scope "..." ``` In CI/CD platforms, store the key as an encrypted secret and reference it in your pipeline config. See the [CI/CD guide](/docs/cicd-database-seeding) for platform-specific examples. > **Best practice:** Create separate API keys per environment (local, staging, CI). This makes it easy to revoke or rotate individual keys without affecting other environments. ## Verify authentication Check that the CLI is authenticated and ready: **macOS / Linux:** ```bash seedfast whoami ``` **Windows:** ```bash npx seedfast whoami ``` This prints your account email and confirms the CLI is connected to your account. --- # Seed Your Database > Source: https://seedfa.st/docs/seed-your-database > Last updated: 2026-09-10 Two commands seed a PostgreSQL database with Seedfast, `seedfast connect` to hand the CLI a connection string and `seedfast seed` to fill the tables. Walk both against a schema that starts out empty, as this page does, and you finish with rows in every table your scope named and every foreign key in them resolved. The last section checks that with a plain `SELECT count(*)`. ## Before you begin Before you seed your database for the first time, Seedfast needs a database it can reach, at least one table inside it, and an account. The [installation guide](/docs/installation) covers Homebrew and npm. For the login itself, and for the API key a pipeline needs in its place, see [authentication](/docs/authentication). The commands and screens below come from real runs against a subscription billing schema, two schemas and eight tables, on PostgreSQL 16 with nothing in it but the DDL. Here are two of those eight tables, enough to read the transcripts against. ```sql CREATE TABLE crm.customers ( id bigserial PRIMARY KEY, name text NOT NULL UNIQUE, domain text NOT NULL UNIQUE, segment text NOT NULL CHECK (segment IN ('startup', 'smb', 'enterprise')), created_at timestamptz NOT NULL ); CREATE TABLE billing.invoices ( id bigserial PRIMARY KEY, subscription_id bigint NOT NULL REFERENCES billing.subscriptions (id), number text NOT NULL UNIQUE, status text NOT NULL CHECK (status IN ('paid', 'overdue', 'disputed')), issued_at date NOT NULL, due_at date NOT NULL, total_amount numeric(12, 2) NOT NULL CHECK (total_amount >= 0), currency char(3) NOT NULL ); ``` ## Connect your database ```bash seedfast connect ``` The prompt asks for a connection string in the ordinary `postgresql://user:password@localhost:5432/mydb` form and stores it for later runs, and if a special character in the password keeps breaking that string, the [connection string builder](/tools/postgres-connection-string-builder) escapes it for you. A script or a CI job has nobody to answer that prompt, so put the same string in the `SEEDFAST_DSN` environment variable instead, the way the [CI/CD guide](/docs/cicd-database-seeding) does. Commands on this page assume a global install. Without one, run them through npx, `npx seedfast connect` and `npx seedfast seed`, which works the same on macOS, Windows and Linux. ## Seed your database for the first time Run `seedfast seed` on its own and Seedfast proposes a scope, then waits for you. With `--scope "a sentence"` on the command line, that sentence takes the place of your approval. ### The proposal and how to change it ```bash seedfast seed ``` The first plan for this schema came back about twenty seconds in. The screen below is that plan with the progress spinner cut. ```text Seeding scope • billing.invoices 50 records • billing.plans 15 records • billing.subscriptions 150 records • crm.addresses 50 records • crm.contacts 50 records • crm.customers 15 records • crm.support_tickets 50 records • crm.ticket_messages 50 records Total: 430 records Do you agree with this scope? > Yes No, seed 500 records per table ``` The highlighted line is the one Enter takes. Select the second line instead and a field opens where you type what you would rather have, which in this session was the sentence "I need at least 500 invoices, keep everything else the same", and the plan came back with invoices at 500 and the other seven lines standing exactly as they were. Ctrl+C at the menu cancels the run. ```text Your feedback: I need at least 500 invoices, keep everything else the same Seeding scope • billing.invoices 500 records • billing.plans 15 records • billing.subscriptions 150 records • crm.addresses 50 records • crm.contacts 50 records • crm.customers 15 records • crm.support_tickets 50 records • crm.ticket_messages 50 records Total: 880 records Do you agree with this scope? > Yes No, social media with posts and followers ``` Approving the second plan finished the run roughly 45 seconds later, and the CLI printed a link to the dashboard. Afterwards a count per table came back with the eight numbers the approved plan listed, including the 500 invoices. ### One sentence on the command line ```bash seedfast seed --scope "seed 50 customers with contacts and invoices" ``` Nothing in this path stops to ask for approval. Under `--output plain` that run logged the following, trimmed to the plan, three of the five table lines and the closing summary. ```text [2026-09-06T17:15:29+02:00] INFO: Seeding started [2026-09-06T17:16:03+02:00] INFO: Tables in scope: crm.customers, crm.contacts, billing.plans, billing.subscriptions, billing.invoices [2026-09-06T17:16:03+02:00] INFO: Planned: 205 records across 5 tables [2026-09-06T17:16:03+02:00] INFO: Auto-approving plan (scope provided) [2026-09-06T17:16:30+02:00] INFO: Table crm.customers completed: 50 rows in 14.677s [2026-09-06T17:16:44+02:00] INFO: Table crm.contacts completed: 50 rows in 13.619s [2026-09-06T17:16:52+02:00] INFO: Table billing.invoices completed: 50 rows in 10.026s [2026-09-06T17:16:52+02:00] INFO: Seeding completed: 5/5 tables succeeded, 205 rows, 84.60s ``` The plan pulled in `billing.plans` and `billing.subscriptions` as well, because an invoice cannot be written without them. The whole run took 85 seconds. When a script has to read the outcome rather than print it, `--output json` reports the same run as a single object carrying `rows_planned` and `rows_actual` per table. ## Check the result Whether the run did what the sentence asked shows up in a row count, one per table, trimmed here to the five tables that were in scope. ```sql SELECT 'crm.customers' AS t, count(*) FROM crm.customers UNION ALL SELECT 'crm.contacts', count(*) FROM crm.contacts UNION ALL SELECT 'billing.plans', count(*) FROM billing.plans UNION ALL SELECT 'billing.subscriptions', count(*) FROM billing.subscriptions UNION ALL SELECT 'billing.invoices', count(*) FROM billing.invoices; ``` Against the database the run above wrote into, it returned five rows. ```text t | count -----------------------+------- crm.customers | 50 crm.contacts | 50 billing.plans | 5 billing.subscriptions | 50 billing.invoices | 50 ``` The stated number, 50 customers, held in every run. Counts the sentence does not fix are chosen by the run, so name the table and the number whenever either one matters. The three tables it never mentioned, `crm.addresses`, `crm.support_tickets` and `crm.ticket_messages`, stayed empty. Read [scoping](/docs/scoping) for what else the flag accepts. ## Troubleshooting **Connection refused, or the connect step times out.** Reach the database with `psql` and that same connection string first. Fix the string until psql connects; the CLI takes it unchanged. **A table you wanted is still empty.** Since tables the sentence does not name stay out of the plan, which is what left three of these eight at zero rows, either name the table or start `seedfast seed` with no scope and add it at the menu. **The run sits there in CI.** In CI the run stalls at that menu waiting for a keypress nobody is there to press. Adding `--scope` logs `Auto-approving plan (scope provided)` and the run continues. For a next step, put a table and a number into the sentence and compare what lands against [scope examples](/docs/scope-examples), which takes this same schema through progressively more detailed scopes. --- # Large-Volume Seeding: Seed Millions of Records > Source: https://seedfa.st/docs/large-volume-seeding > Last updated: 2026-07-24 *Describe what you need in plain English — Seedfast generates realistic data at any scale, directly into your database.* ## How volume works in Seedfast Seedfast generates data at whatever volume you describe. Fifty records for local development, a million for load testing, tens of millions when the tables need to weigh what production weighs — the scope is defined in natural language, and Seedfast builds a plan that matches. At the upper end you are reproducing the working set of a large, high-traffic product, and that is the point of going there: index choices start to matter at those table sizes, and a query plan that looked fine over ten thousand rows can flip under you at fifty million. Volume and realism aren't in tension. Whether you're seeding 50 rows or tens of millions, Seedfast generates values that fit your domain. Names, statuses, timestamps, and relational data are inferred from your schema — not randomized. ```bash seedfast seed --scope "seed 1 million orders with line items" ``` ## How It Works 1. **You describe the scope** — what tables, how many records, any constraints 2. **Seedfast builds a plan** — analyzes your schema, resolves dependencies, determines row counts per table 3. **You see a preview** — table names, record counts, and descriptions 4. **You approve or refine** — if something's off, describe what to change; Seedfast replans without restarting 5. **Data is written directly into your database** — no exports, no intermediate files If there are dependency issues — a table that needs data from another table you didn't include — Seedfast warns you before seeding starts, not halfway through. ## Use Cases ### Load Testing Thirty rows won't surface the slow query that only appears at 100K. You need a database that behaves like production under realistic load. ```bash seedfast seed --scope “seed 1 million orders with line items” ``` ```bash Seeding Plan: public.orders — 1,000,000 records public.order_items — 3,500,000 records public.customers — 200,000 records Total: 4,700,000 records across 3 tables Approve? (Y/n) ``` “Orders with line items” — Seedfast resolved that customers are required to place orders and worked backward through the dependency chain automatically. ### Development A populated database for local development: enough data to test pagination, search, and filters without using production data. ```bash seedfast seed --scope “seed 50 users with orders and invoices” ``` ```bash Seeding Plan: public.users — 50 records public.orders — 250 records public.invoices — 250 records public.order_items — 800 records Total: 1,350 records across 4 tables Approve? (Y/n): I need more orders, at least 500 ``` Type what you'd change instead of pressing `Y`. Seedfast replans: ```bash Updated Seeding Plan: public.users — 50 records public.orders — 500 records public.invoices — 250 records public.order_items — 1,600 records Total: 2,400 records across 4 tables Approve? (Y/n) ``` The session stays alive — no restarting, no re-authenticating. ### Demo and Staging Environments A demo database needs data that looks real — not three rows that reveal it's synthetic. The quality of generated data matters as much as the volume. ```bash seedfast seed --scope “seed 500 customers across 3 regions with support tickets” ``` Seedfast catches dependency problems before writing anything: ```bash Heads up — 1 issue to resolve before seeding public.support_tickets depends on public.agents which has 0 rows. Add public.agents to scope, or remove public.support_tickets from scope. How would you like to proceed ``` ```bash Your answer: add agents too, around 20 Updated Seeding Plan: public.regions — 3 records public.agents — 20 records public.customers — 500 records public.support_tickets — 1,200 records public.ticket_comments — 3,500 records Total: 5,223 records across 5 tables Approve? (Y/n) ``` ### QA and Regression Testing Edge cases only appear when there's volume — and when the scope covers the whole domain. ```bash seedfast seed --scope "seed all e-commerce tables, 10K products and 50K orders" ``` ```bash Scope Exceeds Plan Limit Your scope resolves to 35 tables, but your plan allows 20 per seeding. You can: Refine your scope to reduce the number of tables Upgrade your plan at https://seedfa.st/pricing Describe the updated scope: seed only products, orders, order_items, categories, and customers — 10K products, 50K orders ``` Seedfast replans with the narrower scope: ```bash Seeding Plan: public.categories — 25 records public.customers — 8,000 records public.products — 10,000 records public.orders — 50,000 records public.order_items — 150,000 records Total: 218,025 records across 5 tables Approve? (Y/n) ``` No restart, no guessing which tables to cut. The whole conversation happens in one terminal session. ## Writing Effective Scopes The scope is plain text. You describe what you want, Seedfast interprets it. The better your description, the closer the plan matches your intent. ### What Works Well **Specific table names and counts:** ```bash seedfast seed --scope "seed 1000 users and 5000 orders" ``` **Proportions between tables:** ```bash seedfast seed --scope "seed 100 customers, each with 10 orders" ``` **Schema-level targets:** ```bash seedfast seed --scope "seed all tables in the billing schema, 500 records each" ``` **Volume with relationships:** ```bash seedfast seed --scope "seed 1 million transactions with associated accounts and categories" ``` **Tips** - **Be explicit about numbers.** `"500 users"` beats `"a lot of users"`. - **Name the tables when you can.** `"seed users, orders, and products"` is clearer than `"seed the main tables"`. - **State proportions when they matter.** `"100 customers, each with 5 orders"` gives Seedfast enough context to plan the right ratios. - **Start small, then scale.** Try with 100 records first. Once the plan looks right, bump to 100K. How exactly a stated count, a per-parent ratio and a percentage share come back in the seeded tables, measured over three runs each, is shown in [exact row counts](/docs/exact-row-counts). ## Seeding Plan Preview Before any data is written, you see exactly what Seedfast intends to do: ```bash Seeding Plan: public.users — 1,000 records public.orders — 5,000 records public.order_items — 15,000 records public.products — 200 records public.categories — 12 records Total: 21,212 records across 5 tables Approve? (Y/n) ``` Each line shows the table name and the number of records. If the plan doesn't match — don't approve. Just type what you'd change: ```bash Approve? (Y/n): double the products and add shipping_addresses ``` Seedfast replans and shows an updated preview. Repeat until it's right. ## CLI Quick Reference ### Interactive Mode ```bash # Seedfast asks what to seed — you describe it live seedfast seed ``` ### With --scope ```bash # Auto-approves if the plan fits your subscription seedfast seed --scope "seed 1000 users with orders" ``` When you provide `--scope`, Seedfast skips the interactive prompt, builds the plan, and starts seeding immediately. This is how you use it in scripts and CI/CD pipelines. If the scope exceeds limits in `--scope` mode, Seedfast exits with an error instead of entering interactive replan — because there's nobody at the terminal to refine. ### Output Modes ```bash # Default: rich terminal UI with progress bars seedfast seed --scope "..." # Timestamped logs for CI/CD seedfast seed --scope "..." --output plain # Machine-readable JSON seedfast seed --scope "..." --output json ``` --- # Data Realism > Source: https://seedfa.st/docs/data-realism > Last updated: 2026-07-24 Seedfast doesn't produce random placeholder values. It reads your schema and generates data that fits what your product is actually for. ## How domain inference works Seedfast analyzes table names, column names, data types, and constraints to understand the domain before generating anything. This analysis happens automatically — no configuration required. A table named `orders` with columns like `status`, `total_amount`, and `placed_at` tells Seedfast enough to generate plausible order statuses (`pending`, `shipped`, `delivered`), realistic monetary amounts, and timestamps distributed across a reasonable time window. A table named `patients` with columns like `date_of_birth`, `diagnosis_code`, and `attending_physician_id` gets a different treatment — Seedfast generates data that fits a medical context, not an e-commerce one. ## Examples by domain **E-commerce** Tables like `products`, `orders`, `customers`, `order_items` get: - Product names and descriptions that match the product category - Order statuses drawn from a realistic lifecycle - Pricing with plausible distribution (not uniform random) - Timestamps that reflect realistic purchase patterns **SaaS** Tables like `users`, `workspaces`, `subscriptions`, `activity_logs` get: - Email addresses that look real (not `user1@test.com`) - Workspace names that fit a B2B context - Subscription plans matched to your plan column values - Activity events in logical sequence **HR and internal tools** Tables like `employees`, `departments`, `salaries`, `performance_reviews` get: - Full names with realistic distributions - Job titles and department names that make sense together - Salary ranges calibrated to role and seniority inferred from column context ## Default quality vs. guided quality By default, Seedfast squeezes the maximum quality it can from your schema alone. The more signal available in your schema — descriptive column names, meaningful enum values, well-named foreign keys — the better the results. When you describe what you need in the scope, results improve further: ```bash # Seedfast infers what it can from the schema seedfast seed --scope "seed 100 users" # More context → better data quality seedfast seed --scope "seed 100 enterprise customers, mostly US-based, with active subscriptions" ``` The second command produces customers with company names, US addresses, and subscription records that reflect an active state — because you told Seedfast what "customers" means in your context. ## Languages and formats Write the scope in whatever language you think in. Seedfast reads the instruction rather than matching it against a keyword list, so `zasej 200 používateľov zo Slovenska` produces the same plan as its English equivalent. Generated values follow the same rule. Ask for Japanese customer names and the `name` column fills with Japanese customer names; ask for German postal addresses and the address columns come back shaped the way German addresses are shaped, not a US layout with the words swapped out. Formats work this way too, and the scope is the place to say so. Phone numbers on a particular country code, email addresses on domains you control, product SKUs following an internal pattern, invoice numbers with a prefix and a running counter, URLs that point at your staging host — anything a column holds can be described in a sentence and generated to match. ```bash seedfast seed --scope "seed 500 Slovak customers, +421 phone numbers, emails on acme.sk, invoices numbered INV-2026-0001 upward" ``` Say nothing about any of it and Seedfast still picks a coherent default out of the schema, then holds it steady for the whole run rather than switching conventions halfway through a table. Real runs with Arabic, German and mixed-market scopes, including the checks that confirmed the script and the formats, are in [localized test data](/docs/localized-test-data). ## Refining realism in interactive mode When running `seedfast seed` without `--scope`, Seedfast proposes a plan and shows you what it intends to generate. At this point you can refine not just the volume but the character of the data: ```bash Approve? (Y/n): make the orders use European addresses and EUR currency ``` Seedfast incorporates the instruction and replans. See [Scoping](/docs/scoping) for the full interactive workflow. ## What stays consistent Across all generated data: - **Foreign keys are always valid**, because every row Seedfast writes references a parent that exists in the database - **Relationships are coherent** — an order belongs to a real customer; a subscription belongs to a real workspace - **Enum and check constraints are respected** — status columns only contain values your schema allows - **Data types match exactly** — UUIDs, timestamps, integers, and text fields are generated in the correct format ## Privacy Seedfast generates data from scratch. Your production records are never used as source material or training input. The AI receives only schema metadata — table names, column types, and constraints. See [Privacy & Data Handling](/docs/welcome#privacy--data-handling) for details. --- # Writing a Scope: Four Runs on One Schema > Source: https://seedfa.st/docs/scope-examples > Last updated: 2026-09-06 The scope examples on this page were all run against the same eight tables, held in two schemas named `crm` and `billing`. Keys, NOT NULL, UNIQUE and a few CHECK lists are the whole of the DDL, which leaves the shape of the data to the sentence you write. ```sql CREATE SCHEMA crm; CREATE SCHEMA billing; CREATE TABLE crm.customers ( id bigserial PRIMARY KEY, name text NOT NULL UNIQUE, domain text NOT NULL UNIQUE, segment text NOT NULL CHECK (segment IN ('startup', 'smb', 'enterprise')), created_at timestamptz NOT NULL ); CREATE TABLE crm.contacts ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL REFERENCES crm.customers (id), full_name text NOT NULL, email text NOT NULL UNIQUE, phone text, job_title text, date_of_birth date, created_at timestamptz NOT NULL ); CREATE TABLE crm.addresses ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL REFERENCES crm.customers (id), kind text NOT NULL CHECK (kind IN ('billing', 'shipping')), street_line text NOT NULL, city text NOT NULL, region text, postal_code text NOT NULL, country_code char(2) NOT NULL ); CREATE TABLE crm.support_tickets ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL REFERENCES crm.customers (id), subject text NOT NULL, priority text NOT NULL CHECK (priority IN ('low', 'normal', 'high', 'urgent')), status text NOT NULL CHECK (status IN ('open', 'pending', 'resolved', 'closed')), opened_at timestamptz NOT NULL ); CREATE TABLE crm.ticket_messages ( id bigserial PRIMARY KEY, ticket_id bigint NOT NULL REFERENCES crm.support_tickets (id), author_kind text NOT NULL CHECK (author_kind IN ('customer', 'agent')), body text NOT NULL, sent_at timestamptz NOT NULL ); CREATE TABLE billing.plans ( id bigserial PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL, monthly_price numeric(10, 2) NOT NULL CHECK (monthly_price >= 0), currency char(3) NOT NULL, seat_limit integer CHECK (seat_limit > 0) ); CREATE TABLE billing.subscriptions ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL REFERENCES crm.customers (id), plan_id bigint NOT NULL REFERENCES billing.plans (id), status text NOT NULL CHECK (status IN ('trialing', 'active', 'past_due', 'canceled')), seats integer NOT NULL CHECK (seats > 0), started_at date NOT NULL ); CREATE TABLE billing.invoices ( id bigserial PRIMARY KEY, subscription_id bigint NOT NULL REFERENCES billing.subscriptions (id), number text NOT NULL UNIQUE, status text NOT NULL CHECK (status IN ('paid', 'overdue', 'disputed')), issued_at date NOT NULL, due_at date NOT NULL, total_amount numeric(12, 2) NOT NULL CHECK (total_amount >= 0), currency char(3) NOT NULL ); ``` Each run below started on an empty database with only that file applied, using the published CLI, version 2.6.3, and SQL checked what came out. If you have not seeded yet, [seed your database](/docs/seed-your-database) covers install and connection. ## Running with no scope Type the command with nothing after it and Seedfast reads the schema and proposes a scope of its own. The session below is real, trimmed of spinner frames and account lines. ```bash seedfast seed ``` ```text Seeding scope • billing.invoices 50 records • billing.plans 15 records • billing.subscriptions 150 records • crm.addresses 50 records • crm.contacts 50 records • crm.customers 15 records • crm.support_tickets 50 records • crm.ticket_messages 50 records Total: 430 records Do you agree with this scope? > Yes No, seed 500 records per table ↑↓ to navigate · Enter to select · Ctrl+C to cancel seeding ``` Read the ratios before you press Enter. Fifteen customers carrying 150 subscriptions works out at ten each, and a price book of 15 plans for those same 15 customers comes close to one plan per account, which is the shape a schema suggests when nobody has yet said what the data is for. The second menu item exists because you are expected to move those numbers. Choosing `No` opens a text field. Whatever you type comes back on screen as your feedback, and the plan is rebuilt around it. ```text > No, I need at least 500 invoices, keep everything else the same Your feedback: I need at least 500 invoices, keep everything else the same Seeding scope • billing.invoices 500 records • billing.plans 15 records • billing.subscriptions 150 records • crm.addresses 50 records • crm.contacts 50 records • crm.customers 15 records • crm.support_tickets 50 records • crm.ticket_messages 50 records Total: 880 records Do you agree with this scope? > Yes No, social media with posts and followers ``` One table moved, the other seven stayed put, and the total rose to 880. Approving from there ran the seed to the end. ```text ✓ Seeded 8 tables (880 rows) in 1m 20s ``` One query counts every table here, and it is the block behind each set of counts below. ```sql SELECT 'crm.customers' AS t, count(*) FROM crm.customers UNION ALL SELECT 'crm.contacts', count(*) FROM crm.contacts UNION ALL SELECT 'crm.addresses', count(*) FROM crm.addresses UNION ALL SELECT 'crm.support_tickets', count(*) FROM crm.support_tickets UNION ALL SELECT 'crm.ticket_messages', count(*) FROM crm.ticket_messages UNION ALL SELECT 'billing.plans', count(*) FROM billing.plans UNION ALL SELECT 'billing.subscriptions', count(*) FROM billing.subscriptions UNION ALL SELECT 'billing.invoices', count(*) FROM billing.invoices; ``` ```text t | count -----------------------+------- crm.customers | 15 crm.contacts | 50 crm.addresses | 50 crm.support_tickets | 50 crm.ticket_messages | 50 billing.plans | 15 billing.subscriptions | 150 billing.invoices | 500 ``` Each of the eight numbers matches the approved plan. [Scoping](/docs/scoping) covers the rest of the session, and the runs below pass the scope up front. ## What one sentence buys ```bash seedfast seed --scope "seed 50 customers with contacts and invoices" ``` With `--scope` there is no menu, which is the point of it in a script. The `--output plain` log is trimmed. ```text [2026-09-06T17:16:03+02:00] INFO: Tables in scope: crm.customers, crm.contacts, billing.plans, billing.subscriptions, billing.invoices [2026-09-06T17:16:03+02:00] INFO: Planned: 205 records across 5 tables [2026-09-06T17:16:03+02:00] INFO: Auto-approving plan (scope provided) [2026-09-06T17:16:30+02:00] INFO: Table crm.customers completed: 50 rows in 14.677s [2026-09-06T17:16:44+02:00] INFO: Table crm.contacts completed: 50 rows in 13.619s [2026-09-06T17:16:52+02:00] INFO: Seeding completed: 5/5 tables succeeded, 205 rows, 84.60s ``` Three tables were named and five were filled, since `billing.invoices` carries a NOT NULL reference to `billing.subscriptions`, which carries one to `billing.plans`. ```text t | count -----------------------+------- crm.customers | 50 crm.contacts | 50 crm.addresses | 0 crm.support_tickets | 0 crm.ticket_messages | 0 billing.plans | 5 billing.subscriptions | 50 billing.invoices | 50 ``` The number the sentence states, 50 customers, held in all three runs. Counts it does not fix are left to the run, which is the gap the longer scopes below close by naming a figure per table. ## A sentence with character ```bash seedfast seed --scope "seed 50 enterprise customers based in Germany, each with 2 to 5 active subscriptions on the growth plan, invoices over the last 6 months, mostly paid" ``` That sentence adds a country, a plan name, a date window and a status skew. It ran three times, with the following identical in all three. - 50 customers, `enterprise` on 50 of 50 rows - 50 addresses, country code `DE` and a five-digit postal code on 50 of 50 - one plan row named `Growth`, priced in EUR - 175 subscriptions, 3 for 25 customers and 4 for the other 25 - 1,050 invoices, exactly 6 per subscription, 1,050 of 1,050 in EUR - invoices inside a six-month window ending near the run date (2026-03-11 to 2026-09-04, 2026-03-01 to 2026-08-31, 2026-03-16 to 2026-09-06) - "mostly paid" as 88.6%, 87.6% and 88.6% paid "2 to 5 active subscriptions" came back as 3 or 4 for each of the 50 customers, in each of the three runs. Read a range as one or two values near its middle, and state the number outright when the spread is what a test asserts on. The contact and address rows from run 1 read like this, three rows each, with the contacts trimmed to the name and title columns. ```text full_name | job_title ----------------+----------------- Anna Schmidt | Geschäftsführer Lukas Fischer | Vertriebsleiter Felix Schäfer | Leiter Einkauf street_line | city | region | postal_code | country_code ---------------------+-------------------+---------------------+-------------+-------------- Friedrichstraße 18 | Berlin | Berlin | 10117 | DE Königsallee 47 | Hamburg | Hamburg | 20095 | DE Leopoldstraße 63 | München | Bayern | 80333 | DE ``` ## An eight-sentence brief ```text Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies. 120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups. Company names read like real companies in those countries and each customer has its own email domain. Each customer has 2 to 4 contacts with emails on the company domain; job titles are heads of data, analytics leads and engineering managers; leave dates of birth empty. Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month. Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled. Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed. Support tickets are mostly normal priority and were opened in the last 60 days; subjects read like real questions about dashboards, CSV exports and SSO. Each ticket has exactly 2 messages: a customer question followed by an agent reply. ``` Run it three times and the figures the brief states come back exact in each run. - 120 customers, 360 contacts, 120 addresses, 3 plans, 120 subscriptions, 960 invoices - exactly 1 subscription per customer and 8 invoices per subscription - subscription statuses 96 active, 12 past due, 12 canceled, the 80 / 10 / 10 asked for - invoice statuses 672 paid, 192 overdue, 96 disputed, the 70 / 20 / 10 asked for - plans at 99.00, 399.00 and 1490.00 EUR - invoice numbers `INV-2026-0001` through `INV-2026-0960`, 960 of 960 matching `^INV-2026-\d{4}$` - issued dates monthly, 2026-01-01 to 2026-08-01 - addresses 40 DE, 40 AT, 40 NL - contact emails on the company domain, 360 of 360 - no contact carrying a date of birth, as asked - exactly 2 messages per ticket, customer first and agent second - tickets mostly `normal` priority, 48 of 60 in run 1 The brief fixes where the companies are based and leaves the people to the run, so put the locale for names, phone format and street lines in a sentence of its own. [Localized test data](/docs/localized-test-data) has the scopes for that. ## Asking for one table ```bash seedfast seed --scope "seed only the ticket_messages table, 200 rows" ``` A row in `crm.ticket_messages` needs a ticket, and a ticket needs a customer, so the plan came back with three tables in scope and 225 rows. ```text [2026-09-06T18:06:45+02:00] INFO: Tables in scope: crm.ticket_messages, crm.support_tickets, crm.customers [2026-09-06T18:06:45+02:00] INFO: Planned: 225 records across 3 tables [2026-09-06T18:07:04+02:00] INFO: Table crm.customers completed: 10 rows in 7.244s [2026-09-06T18:07:13+02:00] INFO: Table crm.support_tickets completed: 15 rows in 8.881s [2026-09-06T18:07:22+02:00] INFO: Table crm.ticket_messages completed: 200 rows in 8.353s [2026-09-06T18:07:22+02:00] INFO: Seeding completed: 3/3 tables succeeded, 225 rows, 82.94s ``` ```text t | count -----------------------+------- crm.customers | 10 crm.contacts | 0 crm.addresses | 0 crm.support_tickets | 15 crm.ticket_messages | 200 billing.plans | 0 billing.subscriptions | 0 billing.invoices | 0 ``` The 200 messages landed exactly, and the five tables outside that chain stayed empty. Their two parents were sized by the run, 10 customers and 15 tickets, since the sentence puts a number on the messages alone. This scope ran once, so read those parent numbers as a single observation. Something vaguer gets a plan of its own, and that scope was run once as well. `seed the stuff` planned `430 records across 8 tables`, the same total the session at the top of this page proposed, and wrote them. With `--scope` the plan is approved from the sentence itself, so read the two log lines naming the tables in scope and the planned total before a pipeline trusts the seed. ## What these scope examples say about writing one Six things to take into a scope of your own. - **Name every table you care about.** Tables a scope names hold the counts it gives them, and tables it leaves out are sized by the run. - **Put a number on anything a test reads.** The single figure in "seed 50 customers with contacts and invoices" held at 50 across the three runs. - **State the number when the spread matters.** A range lands as one or two values near its middle, so "2 to 4 contacts" produced exactly 3 for each of the 120 customers. - **Give the locale for people its own sentence**, apart from where the company is based. Naming Germany put `DE` and a five-digit postal code on 50 of 50 addresses. - **State shares as percentages.** All three runs of the brief split invoice status 70 / 20 / 10, the figures it named. - **Say what money looks like.** The three prices written into the brief came back to the cent at 99.00, 399.00 and 1490.00 EUR, and amounts nobody writes down are chosen for you. Take one table next and hold it to an exact number. [Exact row counts and ratios](/docs/exact-row-counts) covers two heavily numbered scopes over three runs each, with the queries for counts per table, children per parent and named status shares. --- # Exact Row Counts, Ratios and Shares > Source: https://seedfa.st/docs/exact-row-counts > Last updated: 2026-09-06 A row count is the cheapest thing to check in a seeded database. Write the exact row counts you want into the scope and checking them afterwards costs one query. Below are two scopes, each run three times against a fresh database holding nothing but the schema, twice with `--output plain` and once with `--output json`. That schema is a subscription billing model spread over two schemas. `crm` holds customers, contacts, addresses, support tickets and ticket messages, `billing` holds plans, subscriptions and invoices. Constraints are keys, NOT NULL, UNIQUE on natural keys, and CHECK on the enum and amount columns, so nothing in the DDL pushes a table towards a size or towards a particular status mix. ```sql CREATE TABLE billing.subscriptions ( id bigserial PRIMARY KEY, customer_id bigint NOT NULL REFERENCES crm.customers (id), plan_id bigint NOT NULL REFERENCES billing.plans (id), status text NOT NULL CHECK (status IN ('trialing', 'active', 'past_due', 'canceled')), seats integer NOT NULL CHECK (seats > 0), started_at date NOT NULL ); ``` ## Exact row counts, table by table The first scope puts a number on each of the eight tables and one distribution on a status column. ```bash seedfast seed --scope "Seed the crm and billing schemas: 200 customers, 600 contacts, 400 addresses, 3 plans, 300 subscriptions, 1,200 invoices with 60% paid, 25% overdue and 15% disputed, 400 support tickets and 1,600 ticket messages." --output plain ``` Run 1 finished in 79.59 seconds. The log is trimmed here to the lines that carry numbers. ```text [2026-09-06T17:20:20+02:00] INFO: Tables in scope: billing.invoices, billing.plans, billing.subscriptions, crm.addresses, crm.contacts, crm.customers, crm.support_tickets, crm.ticket_messages [2026-09-06T17:20:20+02:00] INFO: Planned: 4703 records across 8 tables [2026-09-06T17:20:20+02:00] INFO: Auto-approving plan (scope provided) [2026-09-06T17:20:42+02:00] INFO: Table billing.plans completed: 3 rows in 9.626s [2026-09-06T17:20:54+02:00] INFO: Table crm.contacts completed: 600 rows in 11.946s [2026-09-06T17:21:06+02:00] INFO: Table billing.invoices completed: 1200 rows in 11.818s [2026-09-06T17:21:12+02:00] INFO: Table crm.ticket_messages completed: 1600 rows in 16.124s [2026-09-06T17:21:13+02:00] INFO: Seeding completed: 8/8 tables succeeded, 4703 rows, 79.59s ``` The verification pack then counts the same database. ```text === COUNTS === t | count -----------------------+------- crm.customers | 200 crm.contacts | 600 crm.addresses | 400 crm.support_tickets | 400 crm.ticket_messages | 1600 billing.plans | 3 billing.subscriptions | 300 billing.invoices | 1200 ``` Run 3 was the same scope under `--output json`, which reports planned and written rows per table, trimmed below to two of the eight entries. ```text "billing.invoices": { "rows_planned": 1200, "rows_actual": 1200, "status": "succeeded", "duration_ms": 9733 }, "crm.ticket_messages": { "rows_planned": 1600, "rows_actual": 1600, "status": "succeeded", "duration_ms": 23488 } ``` In that run `rows_planned` and `rows_actual` agree for each of the eight tables, and the two plain runs returned the same eight counts under `SELECT count(*)`. That is eight stated numbers matched in all three runs, `billing.plans` included, which at 3 rows is small enough that a rounding habit would have shown up. Eight numbers came back because eight tables were in the sentence, so name every table you care about and put a number on the ones a test counts. Scopes an order of magnitude larger are covered in [large-volume seeding](/docs/large-volume-seeding), and the mechanics of passing a scope at all are in [Scoping](/docs/scoping). ## Status column shares that were named The same sentence also said what the invoice statuses should look like, `1,200 invoices with 60% paid, 25% overdue and 15% disputed`. Sixty percent of 1,200 is 720, and 720 is what the table holds. ```text === INVOICE STATUS SHARES === status | n | pct ----------+-----+------ paid | 720 | 60.0 overdue | 300 | 25.0 disputed | 180 | 15.0 ``` Runs 2 and 3 returned the same three counts. The `pct` column comes from the verification query dividing each count by the table total; the run does not report percentages about itself. ## Row counts and ratios per parent row The second scope drops the totals for child tables and states counts per parent instead. ```text Seed the crm and billing schemas. 150 customers, each with exactly 4 contacts and 2 addresses. Each customer has 1 to 3 subscriptions, spread over 3 plans. Every subscription has exactly 6 invoices. 300 support tickets with exactly 3 messages each, opened during the last 90 days, with weekdays busier than weekends: a weekend day gets about half the tickets of a weekday. Contact dates of birth centred in the mid-thirties, thinning gradually towards both ends. ``` ```bash seedfast seed --scope "$(cat scope.txt)" --output plain ``` That produced 4,353 rows across the same eight tables in 108.99 seconds. To see the per-parent numbers, count children per parent, then count how many parents sit at each value, so that a uniform result collapses to a single row. ```text === CONTACTS PER CUSTOMER (how many customers have N contacts) === contacts_per_customer | customers -----------------------+----------- 4 | 150 === ADDRESSES PER CUSTOMER === addresses_per_customer | customers ------------------------+----------- 2 | 150 ``` ```text === INVOICES PER SUBSCRIPTION === invoices_per_subscription | subscriptions ---------------------------+--------------- 6 | 300 === MESSAGES PER TICKET === messages_per_ticket | tickets ---------------------+--------- 3 | 300 ``` Each histogram came back as a single row carrying 150 or 300 parents. No customer landed on 3 contacts or on 5, and no ticket came back with 2 messages. In runs 2 and 3 the same four histograms appeared. Writing the child counts per parent rather than as totals left the multiplication to the run, which is where the 600 contacts and 300 addresses under 150 customers come from, along with the 1,800 invoices under 300 subscriptions and the 4,353 rows across the eight tables. ## State the number when the spread matters The same scope asked for `1 to 3 subscriptions` per customer, spread over 3 plans, and 150 customers out of 150 came back with two, in run 1 and again in runs 2 and 3. ```text === SUBSCRIPTIONS PER CUSTOMER === subscriptions_per_customer | customers ----------------------------+----------- 2 | 150 ``` Set that against the first scope, which said nothing about subscriptions per customer and gave only the two totals, 200 customers and 300 subscriptions. There a hundred customers sit on one subscription and a hundred on two, in all three runs. ```text === SUBSCRIPTIONS PER CUSTOMER === subscriptions_per_customer | customers ----------------------------+----------- 1 | 100 2 | 100 ``` Put a number on the count of children per parent whenever a test reads it, and run the histogram once before a test starts depending on a spread rather than on a single value. ## Shapes described in words Three sentences in that scope described a shape rather than a number, a 90 day window for the tickets, weekdays busier than weekends, and dates of birth centred in the mid-thirties. Wording of that kind is read loosely, and the reading belongs to the run. When a test reads the distribution, the numbers belong in the scope, and a query over the seeded table settles what landed. Instructions about the language and the formatting of values are a different matter, covered in [localized test data](/docs/localized-test-data). ## The shares you did not name Neither scope said anything about subscription status or customer segment, and both columns still came back filled. A share the scope does not name is left to the run to choose, so put the shares a test counts, or a demo screen shows, into the sentence as percentages of the table they belong to. Named that way, they land to the row, as the 60, 25 and 15 did above. ## Checking the numbers yourself Both checks on this page are ordinary SQL and belong next to your test suite rather than in a one-off session. The share query divides each status count by the table total. ```sql SELECT status, count(*) AS n, round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct FROM billing.invoices GROUP BY status ORDER BY n DESC; ``` The per-parent check counts children per parent, then counts parents per value, so a uniform result is one row and a spread is several. ```sql SELECT n AS invoices_per_subscription, count(*) AS subscriptions FROM (SELECT subscription_id, count(*) AS n FROM billing.invoices GROUP BY subscription_id) s GROUP BY n ORDER BY n; ``` The two queries above are the whole check, and they belong in the test suite that reads the rows. How much of a scope you need to write before those queries come back the way you want is what [scope examples](/docs/scope-examples) works through, one sentence at a time. --- # Localized Test Data in Arabic, German and a Three-Country Run > Source: https://seedfa.st/docs/localized-test-data > Last updated: 2026-09-06 Localized test data raises two questions worth checking separately, whether values are in the language you asked for and whether they are shaped the way that locale shapes them. The runs below used the schema whose DDL is printed in full on [scope examples](/docs/scope-examples); most of the rows quoted here sit in `crm.customers`, `crm.contacts` and `crm.addresses`, and where a sentence mentioned invoices the billing tables came in as well. Each scope ran three times with the released CLI 2.6.3 against a fresh database holding nothing but that schema, twice with `--output plain` and once with `--output json`, each seed followed by the same verification queries. ## Writing the scope in a language other than English The scope is read as an instruction, not matched against a keyword list, a claim [data realism](/docs/data-realism) makes in a paragraph and this pair of runs checks. One sentence went in twice, in the `--scope` form [scoping](/docs/scoping) describes. ```bash seedfast seed --scope "zasej 50 zákazníkov s kontaktmi a faktúrami" --output plain seedfast seed --scope "seed 50 customers with contacts and invoices" --output plain ``` Both resolved to the same five tables, and the transcripts below are trimmed to three lines each, Slovak first. ```text [2026-09-06T18:09:19+02:00] INFO: Tables in scope: crm.customers, crm.contacts, billing.plans, billing.subscriptions, billing.invoices [2026-09-06T18:09:19+02:00] INFO: Planned: 303 records across 5 tables [2026-09-06T18:09:57+02:00] INFO: Seeding completed: 5/5 tables succeeded, 303 rows, 78.69s ``` ```text [2026-09-06T17:16:03+02:00] INFO: Tables in scope: crm.customers, crm.contacts, billing.plans, billing.subscriptions, billing.invoices [2026-09-06T17:16:03+02:00] INFO: Planned: 205 records across 5 tables [2026-09-06T17:16:52+02:00] INFO: Seeding completed: 5/5 tables succeeded, 205 rows, 84.60s ``` Six runs, three per language, put the same five tables in scope and gave the customers table exactly 50 rows. The tables named without a number moved inside one band, 50 or 100 contacts, 3 or 5 plans, 50 or 100 invoices. Write the scope in Slovak and you have set the language of the instruction, which is a separate setting from the locale of the values, so name the locale you want in a clause of its own. ## Arabic script names and Latin emails on the company domain The Arabic test data below came from one four-sentence scope, two clauses of it negative. ```bash seedfast seed --scope "150 customers, each with 2 contacts. Company names and contact names in Arabic script. Contact emails in Latin script on each company's own domain. No phone numbers and no addresses." --output plain ``` Run 1 finished in 68 seconds, trimmed here to the plan and two tables. ```text [2026-09-06T17:37:55+02:00] INFO: Tables in scope: crm.contacts, crm.customers [2026-09-06T17:37:55+02:00] INFO: Planned: 450 records across 2 tables [2026-09-06T17:38:19+02:00] INFO: Table crm.customers completed: 150 rows in 10.267s [2026-09-06T17:38:34+02:00] INFO: Table crm.contacts completed: 300 rows in 14.644s [2026-09-06T17:38:34+02:00] INFO: Seeding completed: 2/2 tables succeeded, 450 rows, 68.03s ``` Behind that 300 sits 2 contacts for each of the 150 customers, a per parent count the other two runs repeated. The script check counts name columns against Unicode ranges, and the block below is trimmed to the script columns. ```text === SCRIPT CHECKS (names) === contacts | arabic_script | japanese_script | latin_only | mixed_arabic_latin ----------+---------------+-----------------+------------+-------------------- 300 | 300 | 0 | 0 | 0 customers | arabic_script_names | japanese_script_names -----------+---------------------+----------------------- 150 | 150 | 0 ``` 300 of 300 contact names in Arabic script, none carrying a Latin character, and 150 of 150 company names the same, numbers that repeated in all three runs. So did the email clause, checked by comparing the part after the `@` with the customer's `domain` column. ```text === EMAIL DOMAIN MATCH (contact email on the company domain) === contacts | email_on_company_domain ----------+------------------------- 300 | 300 ``` Both negatives held to the row in all three runs, 0 rows in `crm.addresses` and 0 phones. Job titles went unmentioned in that sentence, and run 1 returned them in Arabic beside the names, four rows of which follow, trimmed to the name and title columns. ```text full_name | job_title ---------------+--------------------- أحمد العتيبي | مدير المبيعات محمد القحطاني | مسؤول العمليات علي المالكي | مدير التسويق عمر النجدي | منسق الحسابات ``` Where the emails have to sit on something that reads like a registered company, the `domain` column takes a clause of its own. A second scope repeated those four sentences with that addition, that the domains look like real company domains rather than placeholders, and run 3 of it returned the two rows below, trimmed to the name and domain columns. ```text name | domain ------------------------+------------------- شركة النور التقنية | alnoortech.com مؤسسة الازدهار التقنية | alizdihartech.com ``` ## German names, +49 phones and five-digit postal codes One sentence set the language of the names, the phone prefix, the street line and the postal code format. ```bash seedfast seed --scope "100 customers based in Germany, each with one billing address and 2 contacts. German company names, German contact names, phone numbers on +49, street lines and five-digit postal codes shaped the way German addresses are shaped." --output plain ``` The format check counts rows against a pattern. It is trimmed below to four of its ten columns, the Spanish and Japanese ones being 0 here. ```text === FORMAT CHECKS === de_addresses | de_postal_5_digits | contacts_with_phone | phone_plus49 --------------+--------------------+---------------------+-------------- 100 | 100 | 200 | 200 ``` Those four numbers repeated in all three runs, alongside 100 addresses of kind `billing` and country code DE. Underneath those totals, and again in each of the three runs, the per customer counts came back at 2 contacts and 1 billing address. Run 1 produced the German test data below, the contacts trimmed to four rows and to the name and title columns. ```text full_name | job_title -----------------+-------------------- Lukas Müller | Geschäftsführer Moritz Koch | Einkaufsleiter Anna Hartmann | Vertriebsleiter Katharina Weber | Leiter Finanzen ``` The run's first six billing addresses by id sit on German street names in six different cities. ```text street_line | city | region | postal_code | country_code -------------------+-------------------+---------------------+-------------+-------------- Hauptstraße 1 | Berlin | Berlin | 10115 | DE Bahnhofstraße 8 | Hamburg | Hamburg | 20095 | DE Gartenstraße 15 | München | Bayern | 80331 | DE Goethestraße 22 | Köln | Nordrhein-Westfalen | 50667 | DE Schillerstraße 29 | Frankfurt am Main | Hessen | 60311 | DE Kaiserstraße 36 | Stuttgart | Baden-Württemberg | 70173 | DE ``` In all three runs the same city and postal code pairs turned up, on German street names, with German legal forms on the company names (GmbH, AG, KG and GmbH & Co. KG in run 1) and German job titles. ## Multilingual test data across three countries The last scope splits a single run across three countries by percentage. ```bash seedfast seed --scope "200 customers, each with one billing address and one contact: 50% in Germany, 30% in Spain, 20% in Japan. Company names, contact names, street addresses, postal codes and phone numbers local to each customer's country." --output plain ``` Across the three runs 200 customers came back with one contact and one billing address apiece. Runs 1 and 3 landed exactly on 100, 60 and 40 addresses, the behaviour named shares show on [exact row counts](/docs/exact-row-counts). ```text === ADDRESS COUNTRIES === country_code | n | pct --------------+-----+------ DE | 100 | 50.0 ES | 60 | 30.0 JP | 40 | 20.0 ``` Postal formats matched the country on 100% of addresses in all three runs, and run 1 is below, trimmed to the six postal columns. ```text de_addresses | de_postal_5_digits | es_addresses | es_postal_5_digits | jp_addresses | jp_postal_nnn_nnnn --------------+--------------------+--------------+--------------------+--------------+-------------------- 100 | 100 | 60 | 60 | 40 | 40 ``` Phone country codes, counted the same way, lined up with the address split in run 1, 100 on +49, 60 on +34 and 40 on +81. ## The two queries behind these tables Most of the numbers above come from two queries, and the first counts names against Unicode ranges. ```sql SELECT count(*) AS contacts, count(*) FILTER (WHERE full_name ~ '[؀-ۿ]') AS arabic_script, count(*) FILTER (WHERE full_name ~ '[぀-ヿ一-鿿]') AS japanese_script, count(*) FILTER (WHERE full_name ~ '^[A-Za-zÀ-ɏ .''-]+$') AS latin_only, count(*) FILTER (WHERE full_name ~ '[؀-ۿ]' AND full_name ~ '[A-Za-z]') AS mixed_arabic_latin, count(DISTINCT full_name) AS distinct_names FROM crm.contacts; ``` The second counts formats with regexes. Four of its ten columns are shown. ```sql SELECT (SELECT count(*) FROM crm.addresses WHERE country_code = 'DE' AND postal_code ~ '^\d{5}$') AS de_postal_5_digits, (SELECT count(*) FROM crm.addresses WHERE country_code = 'JP' AND postal_code ~ '^\d{3}-\d{4}$') AS jp_postal_nnn_nnnn, (SELECT count(*) FROM crm.contacts WHERE phone ~ '^\+49') AS phone_plus49, (SELECT count(*) FROM crm.contacts WHERE phone ~ '^\+81') AS phone_plus81; ``` Formats with nothing to do with locale check the same way. On this schema a brief asking for invoice numbers from INV-2026-0001 gave 960 of 960 rows matching `number ~ '^INV-2026-\d{4}$'`, up to INV-2026-0960. ## How to ask for localized test data Name the tables you care about and put a number on each one, since a table the sentence mentions without a count is left for the run to size. A share written as a percentage of a stated total gives the run something exact to hit, the way the three-country scope named 50, 30 and 20 against 200 customers. Give the locale for people, phone numbers and street addresses a clause separate from the one saying where the company is based, and say which script the values belong in. Before a demo, read a sample of the free-text columns with the queries above, the check that turned 300 rows of Arabic script into a number you can assert on. Start from the DDL on [scope examples](/docs/scope-examples) if you want to reproduce any run on this page; every table above came from that file. --- # How It Works > Source: https://seedfa.st/docs/how-it-works > Last updated: 2026-04-15 Seedfast connects to your database, reads its schema, generates realistic data, and writes it directly back — in a single operation. There's nothing to copy, configure, or export. ## Schema-driven generation When you run `seedfast seed`, the first thing Seedfast does is read your schema: - Table and column names - Data types and constraints - Foreign key relationships - Check constraints and enum values This is the only input Seedfast needs to generate data. It doesn't require a configuration file, a separate schema definition, or any manual setup. You connect a database and it works with what's there. The schema analysis happens server-side. Your credentials stay local — only metadata (table names, column types, constraints) is transmitted. Your actual data is never sent anywhere. ## Direct to your database Seedfast writes data directly into your database tables. There are no intermediate steps: - No CSV exports to import later - No SQL files to run manually - No browser downloads or dashboard interactions You run `seedfast seed`, data appears in your tables. This works identically for local databases and remote ones — the same command, the same workflow. ```bash # Local database seedfast seed --scope "50 users with orders" # Remote database — same command, different connection seedfast connect postgres://user:pass@remote-host:5432/db seedfast seed --scope "50 users with orders" ``` ## The generation pipeline Once the schema is read, Seedfast runs through a planning phase before writing anything: 1. **Domain inference** — analyzes table and column names to understand what the data represents 2. **Validation** — makes sure the planned rows will be valid before anything is written 3. **Plan generation** — produces a per-table plan with record counts and descriptions 4. **Review** — shows you the plan before any data is written; you approve or refine 5. **Seeding** — writes data directly into your tables, connected and valid The review step means you always see exactly what will happen before it happens. If the plan doesn't match what you need, describe what to change — Seedfast replans without restarting the session. ## Handling schema changes Seedfast reads your live schema every time it runs. If you add a table, rename a column, or add a foreign key, Seedfast picks it up automatically on the next run — no configuration updates needed. If you provide a `--scope` that references tables that no longer exist, or omits a table that's now required as a dependency, Seedfast detects this before seeding starts and tells you how to resolve it. ## Trigger handling Seedfast is aware of database triggers. When seeding, it accounts for triggers that might fire on insert — so the final state of your database reflects both the inserted data and any trigger effects, not just the raw inserts. This means the data you get is consistent with how your application actually writes to the database. --- # Scoping > Source: https://seedfa.st/docs/scoping > Last updated: 2026-04-15 The scope tells Seedfast what to generate — which tables, how many records, and what kind of data. You can define it interactively in the terminal or pass it directly via the `--scope` flag. ## Default behavior When you run `seedfast seed` without any scope, Seedfast analyzes your schema and proposes a reasonable default scope on its own. It picks a set of tables that form a coherent dataset and suggests record counts that make sense for the schema size. This default is good enough to get started, but explicit scoping produces better results. The more precisely you describe what you need, the better Seedfast can match it — both in structure and in the realism of the generated data. ## Interactive mode Interactive mode starts when you run `seedfast seed` without `--scope`. Seedfast proposes a scope, shows you the plan, and waits for your input before writing anything. **Adding tables to scope:** If the proposed plan doesn't include a table you need: ```bash Approve? (Y/n): also include the invoices table ``` Seedfast replans with the added table, resolving any new dependencies automatically. **Removing tables from scope:** ```bash Approve? (Y/n): skip audit_logs and system_events ``` **Adjusting record counts:** ```bash Approve? (Y/n): I need at least 500 orders, keep everything else the same ``` **Refining data quality:** The scope isn't just about which tables and how many rows — it also guides the character of the data: ```bash Approve? (Y/n): make customers US-based enterprise companies, not individuals ``` ```bash Approve? (Y/n): orders should be mostly in a completed or shipped state, not pending ``` These instructions become part of the generation context. Seedfast incorporates them and replans. You can refine as many times as needed — the session stays alive, no restarting. ## CLI mode (`--scope` flag) Pass `--scope` to skip the interactive prompt entirely. Seedfast builds the plan and starts seeding immediately: ```bash seedfast seed --scope "seed 100 users with orders and invoices" ``` This is how you use Seedfast in scripts, CI/CD pipelines, or any non-interactive environment. For the same schema taken through four scopes of increasing detail, with the plan and the resulting rows for each, see [scope examples](/docs/scope-examples). **What to put in the scope:** Be explicit about tables and counts: ```bash seedfast seed --scope "seed users, orders, and products — 200 users, 1000 orders" ``` Describe relationships when they matter: ```bash seedfast seed --scope "seed 50 enterprise customers, each with 5-10 active subscriptions" ``` Exclude tables explicitly: ```bash seedfast seed --scope "seed all tables in the public schema except audit_logs" ``` Add context about the data itself: ```bash seedfast seed --scope "seed 100 employees across 5 departments, mix of seniority levels" ``` **If the scope is vague or leaves a dependency open**, `--scope` mode completes the plan on its own rather than entering an interactive loop, because there is no one at the terminal to resolve it, and a parent table a scoped table depends on is added to the plan. The log prints the tables in scope and the planned total before anything is written, which is where a scripted run gets checked; [scope examples](/docs/scope-examples) shows three such sentences and what each one produced. In interactive mode the same situations end in the proposal menu, where you can refine before approving. ## Auto-adaptation to schema changes Seedfast reads your live schema on every run. When your schema changes — new tables, renamed columns, added foreign keys — Seedfast picks it up automatically. You don't need to update scope instructions for changes that don't affect what you're seeding. If a change breaks your scope (a table you reference was renamed, or a new required dependency appeared), Seedfast detects it and tells you before writing anything. ## More detail = better quality Scoping affects not just structure but data quality. Every piece of context you provide — domain, geographic region, status distribution, user type — improves the realism of what gets generated. Seedfast always tries to squeeze the maximum quality from the schema alone. But the schema has limits: it can tell Seedfast there's a `status` column with values `active` and `inactive`, but it can't tell Seedfast what ratio makes sense for your use case. The scope can. See [Data Realism](/docs/data-realism) for more on how Seedfast uses context to improve output quality, and [exact row counts](/docs/exact-row-counts) for what a number, a ratio or a percentage in the scope does to the rows, measured over three runs. --- # Seeding > Source: https://seedfa.st/docs/seeding > Last updated: 2026-04-15 Seeding is the process of writing generated data into your database. Seedfast does this directly — there's no intermediate format, no file to import, no manual SQL to run. ## Direct database writes When a seed runs, Seedfast connects to your database and inserts records table by table, in the order determined during the planning phase. Data lands in your tables exactly as it would from your application — as rows in PostgreSQL, immediately queryable. After seeding, you can use any tool you already use with your database: - Query with `psql` or any SQL client - Inspect in a GUI like pgAdmin, DBeaver, or TablePlus - Use immediately for development, testing, or demo environments - Export to any format if needed Nothing special is required. The data is just there. ## Local and remote databases Seedfast seeds local and remote databases using the same workflow. Point it at a connection string — the rest is identical: ```bash # Local seedfast connect # prompts for connection string: postgresql://user:pass@localhost:5432/mydb # Remote — set connection string directly seedfast connect postgres://user:pass@remote-host:5432/db seedfast seed --scope "..." ``` This makes it straightforward to seed staging environments, review app databases, or any remote PostgreSQL instance you have credentials for. ## Trigger-aware seeding Seedfast accounts for database triggers when inserting data. If your schema has triggers that fire on insert — updating timestamps, maintaining denormalized counters, enforcing business rules — Seedfast handles this correctly. The data you end up with reflects the actual state of your database after triggers run, not just the raw inserted values. This matches how your application writes data and means the seeded state is consistent with real usage. ## Foreign keys and incomplete scopes Seedfast hands the database a dataset it accepts as the rows land. Every foreign key in the generated data points at a row that is already there, so no constraint is violated on the way in. If your scope includes a table that depends on another table not in scope, Seedfast catches this before the run starts and asks you to resolve it. You won't discover a dependency failure halfway through a large seed. ## What gets seeded Only the tables in your scope are touched. Existing rows in those tables are not modified or deleted — Seedfast appends new records. If you need a clean state before seeding, truncate the relevant tables first: ```bash # DATABASE_URL is your connection string, e.g. postgresql://user:pass@localhost:5432/mydb psql $DATABASE_URL -c "TRUNCATE users, orders, products RESTART IDENTITY CASCADE;" seedfast seed --scope "seed 100 users with orders and products" ``` --- # MCP Setup Guide > Source: https://seedfa.st/docs/mcp-setup-guide > Last updated: 2026-09-10 The Model Context Protocol (MCP) enables AI assistants to interact directly with developer tools. Seedfast's MCP server brings database seeding into that workflow, so you ask your assistant instead of switching to a terminal. This guide walks through connecting Seedfast MCP to Claude Desktop, Cursor IDE, VS Code, or Claude Code CLI. ## Understanding MCP Architecture Before diving into configuration, it helps to understand what MCP actually does: ``` ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ AI Assistant │ ◄───► │ Seedfast MCP │ ◄───► │ Your Database │ │ (Claude/Cursor) │ │ Server │ │ (PostgreSQL) │ │ │ │ │ │ │ │ Natural language │ │ JSON-RPC protocol │ │ SQL execution │ │ commands │ │ Tool orchestration │ │ Data generation │ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘ ``` The MCP server acts as a bridge between your AI assistant and Seedfast's backend. When you ask Claude to "seed my database with test users," the assistant invokes MCP tools that execute the actual seeding operations. ## Prerequisites Before starting, ensure you have: - A **Seedfast account** (free plan at [seedfa.st](/login)) - **PostgreSQL** database accessible from your machine - **Node.js 18+** installed (for npx-based MCP server) - One of: Claude Desktop, Cursor IDE, VS Code with Continue.dev, or Claude Code CLI ## Installation No separate installation required. The MCP server is built into the Seedfast CLI and runs via **npx** directly from your configuration. ### Pin the version Every example below asks for an exact version rather than `seedfast@latest`. That matters because your MCP config is a file your whole team runs from, and `@latest` re-resolves on every server start. We ship often enough that two people on the same branch in the same week can end up on different builds, which turns "works on my machine" into a question nobody can answer from the config alone. Pin it, and bump the pin when you choose to: ```bash npm view seedfast version # what's current ``` For a throwaway local experiment, `@latest` is fine. Anything committed, shared, or running in CI should name a version. One caveat worth knowing: the CLI talks to the Seedfast API, so a pin you leave untouched for many months can eventually fall behind what the API expects. Treat bumping it as routine maintenance rather than something you do only when a run breaks. ### Keep the API key out of the file Four of the five clients here can read the key from your environment instead of storing it in the config, which is what you want for any file that lives in a repository. Each one spells it differently, and the sections below use the right syntax for each. Claude Desktop is the exception and needs a literal value, though its config sits in your OS application-support directory rather than your project, so it is not something you would commit by accident. Export the key once in your shell profile: ```bash export SEEDFAST_API_KEY="sfk_live_your_actual_key_here" ``` ## Configure Claude Desktop Claude Desktop is the official Anthropic client with native MCP support. **Locate your config file:** - **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` - **Linux:** `~/.config/Claude/claude_desktop_config.json` **Add the Seedfast server:** ```json { "mcpServers": { "seedfast": { "command": "npx", "args": ["-y", "seedfast@2.6.3", "mcp"], "env": { "SEEDFAST_API_KEY": "sfk_live_your_api_key_here" } } } } ``` Claude Desktop does not expand variables in this file, so the key has to be written out in full. Because the config lives in your application-support directory and not in a project, that is a smaller problem than it looks, but the file does hold a usable credential in plain text and deserves the same care as any other dotfile that does. **Restart Claude Desktop** to load the new configuration. ## Configure Cursor IDE Cursor runs MCP servers in a sandboxed environment. Authentication is configured directly in the MCP config's `env` section. **Add to** `.cursor/mcp.json` **or global settings:** ```json { "mcpServers": { "seedfast": { "command": "npx", "args": ["-y", "seedfast@2.6.3", "mcp"], "env": { "SEEDFAST_API_KEY": "${env:SEEDFAST_API_KEY}" } } } } ``` Cursor interpolates `${env:NAME}` in `command`, `args`, `env`, `url` and `headers`, so `.cursor/mcp.json` can be checked in as it stands and each person supplies their own key through the environment. ## Configure VS Code with Continue.dev Continue.dev provides MCP support for VS Code users. **Add to** `.continue/config.json`**:** ```json { "experimental": { "modelContextProtocolServers": [ { "transport": { "type": "stdio", "command": "npx", "args": ["-y", "seedfast@2.6.3", "mcp"], "env": { "SEEDFAST_API_KEY": "${{ secrets.SEEDFAST_API_KEY }}" } } } ] } } ``` Continue resolves `${{ secrets.NAME }}` in `args` and `env` against its own secret store, so the key never appears in `config.json`. ## Configure Claude Code CLI For terminal-based workflows with Claude Code: **Add to your** `.mcp.json`**:** ```json { "mcpServers": { "seedfast": { "command": "npx", "args": ["-y", "seedfast@2.6.3", "mcp"], "env": { "SEEDFAST_API_KEY": "${SEEDFAST_API_KEY}" } } } } ``` Claude Code expands `${VAR}` and `${VAR:-default}` in `command`, `args`, `env`, `url` and `headers`. Since `.mcp.json` is meant to be committed so everyone on the team picks up the same servers, referencing the variable is the whole point: the file describes the setup and your shell supplies the credential. ## Claude Code skill The MCP server hands Claude the tools. A skill hands it the procedure. Without one, an agent works the order out from the tool descriptions on every fresh conversation, which is usually close enough and occasionally not, and what slips is the schema read, or the reply from `seedfast_run` being reported as a finished job when it only acknowledges that a run has started. Seedfast ships two of them. The `seedfast` skill pins the sequence, carries the scope-writing conventions measured on [scope examples](/docs/scope-examples), points at the prompts the MCP server publishes for the harder cases, and spells out the rules about production databases and invented table names. When the tools are missing instead, or the key is rejected, or a connection string will not resolve, `seedfast-setup` is what loads, and it walks through the fix without ever asking you to paste the key into the chat. Claude Code decides which one a conversation needs, so there is no command to remember and no cost when the subject never comes up. ### Install with the plugin The Seedfast plugin bundles the MCP server configuration with both skills, and Claude Code asks for your API key while installing it, then keeps that key in its own storage instead of a file you might commit. ``` /plugin marketplace add seedfast-ai/claude-plugins /plugin install seedfast@seedfast ``` Skip the marketplace and copy the folders directly if a pinned Claude Code build predates plugin support, or the team just prefers files it can review in a diff. ### Install by copying the folders The npm package carries both skills under `skills/`. Copying them into a project means everyone who clones the repository picks them up: ```bash mkdir -p .claude/skills cp -R "$(npm root -g)/seedfast/skills/seedfast" .claude/skills/ cp -R "$(npm root -g)/seedfast/skills/seedfast-setup" .claude/skills/ ``` Point the same two commands at `~/.claude/skills` instead and the skills follow you across every project on the machine. With Seedfast installed as a project dependency rather than globally, the source path is `node_modules/seedfast/skills/...`, and Windows PowerShell wants `Copy-Item -Recurse` for the same job. Restart Claude Code afterwards and run `/skills`, which should now list `seedfast` and `seedfast-setup`. Should the version you pinned predate the release that added these folders, nothing is lost. The seeding skill is reproduced below in full. Save it as `.claude/skills/seedfast/SKILL.md`, restart, and you have the same behaviour without waiting on an upgrade. ````markdown --- name: seedfast description: Fill a PostgreSQL database with realistic, relationally valid test data using Seedfast over MCP. Use when the user wants to seed, populate or fill a database, needs test, demo or staging data, has empty tables to work against, wants a dev database that behaves like production, or says "seed the database", "seed my database", "populate my postgres with test data", "fill the staging database", "generate test data for these tables", "my dev database is empty", "I need demo data", "fixtures", "synthetic data" or "seedfast". Covers the environment check, the connection test, reading the schema, writing the plain-language scope, previewing a plan, starting a run, polling it to completion, answering a question the run raises, and counting what landed. --- # Seeding a database with Seedfast Seedfast reads a live PostgreSQL schema and generates data that satisfies it: foreign keys resolve, constraints hold, and values look like the domain rather than `test_user_1`. The work happens on Seedfast's backend, and the MCP tools here drive it. ## The loop ``` doctor -> connections_test -> schema_info -> plan -> [user approves] -> run -> run_status (poll) -> count ``` Skipping straight to `seedfast_run` is allowed, but only when the user has clearly said "just seed it" and the target is obviously disposable. ### 1. Check the environment Call `seedfast_doctor` first, once per session. It reports CLI status and version, whether the API key is configured, and the platform. If it reports a missing API key, stop and route the user to the `seedfast-setup` skill instead of retrying. If the `seedfast_*` tools are not visible at all, the MCP server is not registered with the client. That is also `seedfast-setup`, and no amount of retrying makes the tools appear. ### 2. Verify the connection before anything else `seedfast_connections_test` with the DSN. It opens a pool and pings with a 10-second timeout, and masks credentials in its output. This catches a wrong password or a closed firewall port in one second instead of surfacing it as a confusing planner failure a minute later. DSN format: ``` postgres://user:password@host:5432/dbname postgres://user:password@host:5432/dbname?sslmode=require ``` Never print a DSN back to the user with the password intact. When you need to refer to a database, name it (`the staging DB`), don't echo the string. ### 3. Read the schema before writing a scope `seedfast_schema_info` returns tables, columns, primary keys, foreign keys, and approximate row counts. Use it to ground the scope in tables that actually exist. Row counts come from `pg_class.reltuples`. They are approximate, can be stale between `ANALYZE` runs, and are `-1` on never-analyzed tables. Treat them as a size hint, never as a fact to report. The `dsn` argument is optional here. With it omitted, the server falls back to `SEEDFAST_DSN` or `DATABASE_URL` in its own environment. ### 4. Plan, then let the user look `seedfast_plan` generates a plan without writing a single row, and stores it in the session. It returns a plan ID, the scope echoed back, and the table list. Show the user the table list and the scope before running. This is the whole point of the plan step: it is the last cheap moment to catch "that scope also touches `billing_invoices`". `seedfast_plan` requires an API key. ### 5. Run it `seedfast_run` returns immediately with a `runId`, and the seeding proceeds in the background. - Pass `planId` to execute an approved plan. The scope is derived from the plan's tables and the `scope` argument is ignored. - Without `planId`, `scope` is required. - Always pass an `idempotencyKey`. A retry carrying the same key returns the existing run instead of seeding twice, which is the difference between a dropped connection costing you nothing and costing the user a doubled `orders` table. ### 6. Poll to completion `seedfast_run_status` is safe to call repeatedly. It reports state (`pending`, `running`, `awaiting_input`, `completed`, `failed`, `cancelled`), progress as completed vs total tables, row totals, the table currently being seeded, and a summary once finished. Poll at a human pace, a few seconds between calls rather than a tight loop. Report progress to the user as it moves rather than going silent for two minutes. ### 7. Answer questions the run raises A run can move to `awaiting_input` when the backend needs a decision about scope or a replan. `seedfast_run_status` surfaces the `questionId`, and the full text is at `seedfast://runs/{runId}/pending_question`. Reply with `seedfast_run_answer`: - `answer.human_answer = true` approves the current plan or scope as-is. - `answer.human_answer = false` plus `answer.raw` with a textual refinement (`"seed only the org schema"`) adjusts it. **Bring the question to the user.** Do not auto-approve on their behalf, because the backend asks precisely when the right call is not obvious. The CLI blocks for up to 5 minutes waiting for the reply, so answer promptly once the user decides. ### 8. Count what landed Run `SELECT count(*)` against the tables the scope named and put those numbers beside the ones the scope asked for. A `completed` status says the run finished. Only the counts say it filled the database the user had in mind. ## Writing scopes Scopes are plain English, interpreted by the backend. Do not pre-parse them into a DSL. ``` seed the users and posts tables with 1000 rows each HR schema only everything except the audit and billing tables enough orders across 50 customers to exercise the reporting dashboard ``` Two things make a scope good: naming real tables from `seedfast_schema_info`, and saying how much. "Some test data" produces a plan nobody can review. For more patterns, request the server's `scope-examples` prompt (`general`, `ci`, or `exploration`). For a production-shaped target, request the `seed-production-db` prompt before starting. Worked descriptions of four different sizes, with the row counts each one produced on the same schema, are at https://seedfa.st/docs/scope-examples. Keep the final wording in the repository next to the migrations, since the same text feeds `seedfast seed --scope` at a terminal and a CI step behind an API key. ## Safety **Seedfast writes rows to a real database.** Before the first `seedfast_run` of a session, confirm the target is a development, staging, or test database. If the DSN host looks production-shaped (`prod`, `live`, a customer domain, an RDS writer endpoint), stop and ask outright. **Cancellation does not roll back.** `seedfast_run_cancel` stops the run, but rows already inserted stay inserted. A cancelled run leaves a partially seeded database that someone has to clean up. Say so when you cancel. **`seedfast_plan_delete` is irreversible** and does not touch runs started from that plan. ## Guardrails **Never invent a table name.** Every table a scope names comes from `seedfast_schema_info`. A guessed name produces nothing and reports nothing about having produced nothing. **Never read success out of the `seedfast_run` response.** That call returns before the first insert, carrying a run ID and `pending`. Only `seedfast_run_status` reporting `completed` describes a result. **Never start a second run to fix a slow one.** Poll it, or cancel it and then start a single run with a corrected scope. Two runs against the same tables leave a mess that has to be cleaned up by hand. **Counted rows are the only real evidence.** Status text describes what the backend thinks it did. The counts from step 8 are what you report to the user. ## Plans Plans live in the MCP session, in memory, unless the user configured a run-history file. They do not survive an MCP server restart. Do not promise a user that a plan will be there tomorrow. | Tool | Use | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `seedfast_plans_list` | Find plan IDs (accepts `limit`) | | `seedfast_plan_get` | Full table list and preview for one plan | | `seedfast_plan_create` | Store a hand-built plan, skipping the planner round-trip. Needs `scope` and at least one entry in `tables` | | `seedfast_plan_update` | Change `tables`, `scope`, or `preview`. Only non-empty fields overwrite, omitted fields are preserved, and there is no way to clear a field back to empty | | `seedfast_plan_delete` | Discard a plan. Irreversible | `seedfast_plan_create` is the fast path when the tables are already known from a previous run, since it needs no API key and no backend call. ## Resources Read these directly when the status text is not enough: | URI | Contents | | ------------------------------------------ | ----------------------------------------------------- | | `seedfast://runs/{runId}/summary` | Run summary as JSON | | `seedfast://runs/{runId}/log` | Event log, NDJSON, the place to look when a run fails | | `seedfast://runs/{runId}/pending_question` | The question an `awaiting_input` run is blocked on | | `seedfast://plans/{planId}` | Full plan as JSON | ## When a run fails 1. `seedfast_run_status` for the error message and the failed-tables map. 2. `seedfast://runs/{runId}/log` for the events leading up to it. 3. Read the failure before re-running. A constraint the generator could not satisfy will fail again identically, so the scope or the schema is what needs to change. ## Which tools need the API key Only `seedfast_plan` and `seedfast_run` reach the Seedfast backend. Everything else works without a key: `doctor`, `connections_test`, `schema_info`, all plan management, run status, cancel, and answer. A missing key is not a reason to abandon schema exploration. ## Scale PostgreSQL only today. MySQL, Oracle, and SQLite are in development. If the user points this at a MySQL database, say that plainly rather than trying the DSN. ## Reference - [MCP setup guide](https://seedfa.st/docs/mcp-setup-guide) has the config block each client expects, the API key, and the troubleshooting cases. - [Seeding with an AI agent](https://seedfa.st/docs/ai-agent-seeding) walks one complete session, with the calls, the description, and the SQL that counted the rows afterwards. ```` Then ask for what you want in ordinary words, naming the database and roughly how much data you need. The agent checks the environment, tests the connection, reads the schema, drafts the description, waits for your approval and only then starts writing rows. [Seeding with an AI agent](/docs/ai-agent-seeding) shows what that session looks like from the first call to the counting queries at the end. ## Verify Installation After configuration, verify the MCP server is accessible. In your AI assistant, ask: ``` Use seedfast_doctor to check the installation ``` Here is what the check returns. The path is specific to the machine it ran on, so it appears here as ``, and the `Platform` line will differ on a Mac or Linux machine: ``` CLI Status: OK Version: seedfast 2.6.3 backend 2.0.0 Path: Auth: OK (SEEDFAST_API_KEY configured) Platform: windows/amd64 Go Version: go1.25.1 MCP Server Version: 1.0.0 ``` ## Configure Authentication Seedfast MCP uses config-based authentication via the `env` section in your MCP configuration. **Get Your API Key:** 1. Log in to [seedfa.st](https://seedfa.st) 2. Open **Settings**, then **API Keys** 3. Click **Create New Key** 4. Copy the key (format: `sfk_live_xxxxx...`) **Point the config at the key:** Export it in your shell profile so the value lives in one place: ```bash export SEEDFAST_API_KEY="sfk_live_your_actual_key_here" ``` Then reference it from the `env` section. Each client has its own syntax: | Client | Config file | Value to use | | -------------- | ---------------------------- | --------------------------------- | | Claude Code | `.mcp.json` | `${SEEDFAST_API_KEY}` | | Cursor | `.cursor/mcp.json` | `${env:SEEDFAST_API_KEY}` | | Continue.dev | `.continue/config.json` | `${{ secrets.SEEDFAST_API_KEY }}` | | Codex CLI | `config.toml` | `env_vars = ["SEEDFAST_API_KEY"]` | | Claude Desktop | `claude_desktop_config.json` | the literal key, no expansion | Codex is the odd one out in shape rather than intent: instead of substituting a value it whitelists the variable name and forwards whatever your shell already has. In CI, set `SEEDFAST_API_KEY` as a pipeline secret and the same committed config keeps working without a local edit. ## Your First AI-Powered Seed With everything configured, try your first seeding operation. **Test Database Connection:** ``` Test the database connection to postgresql://myuser:mypass@localhost:5432/mydb ``` **Run a Seed:** ``` Seed all tables in all schemas of my database at postgresql://myuser:mypass@localhost:5432/mydb ``` Your assistant runs the seed in the background and reports progress as it goes. How to write the description, and a real session end to end, is on [seeding with an AI agent](/docs/ai-agent-seeding). ## Available MCP Tools Two tools need `SEEDFAST_API_KEY` in the server's environment, `seedfast_plan` and `seedfast_run`. The others read local session state or the database and work without it. **Checks:** - `seedfast_doctor` reports CLI status and version, the binary path, whether the key is set, the platform, and the Go and MCP server versions. - `seedfast_connections_test` opens a connection to the database at the given DSN and returns success or failure, with the credentials masked. - `seedfast_schema_info` reads the schema and returns tables, columns, primary keys, foreign keys and approximate row counts as JSON. **Runs:** - `seedfast_run` starts a seeding run in the background and returns the run ID with its initial status right away. - `seedfast_run_status` returns a run's state and progress, and its summary once it has finished. - `seedfast_run_answer` submits an answer to a run that is waiting on a question and returns a short confirmation. - `seedfast_run_cancel` requests cancellation of a pending or running operation. Rows already written stay in the database. **Plans:** - `seedfast_plan` builds a seeding plan for a scope without writing data, stores it for the session and returns the plan ID with its table list. - `seedfast_plans_list` lists the plans stored in the current session. - `seedfast_plan_get` returns one stored plan by its ID. - `seedfast_plan_create` stores a plan you supply directly, skipping the planning round trip. - `seedfast_plan_update` changes the tables or scope on a stored plan. - `seedfast_plan_delete` removes a stored plan. ## MCP Resources: Beyond Tools Seedfast MCP also exposes **resources**, which are read-only data endpoints an AI assistant can read for context instead of calling a tool. - `seedfast://plans/{planId}` returns a stored plan's details, as JSON. - `seedfast://runs/{runId}/summary` returns a run's status and results, as JSON. - `seedfast://runs/{runId}/log` streams a run's events as NDJSON. - `seedfast://runs/{runId}/pending_question` returns the question a run is waiting on while its state is `awaiting_input`, as JSON. ## MCP Prompts - `seed-production-db` walks the assistant through checking the environment, testing the connection, planning, and running a seed in that order, and takes a required `scope` argument for what to seed plus an optional `dsn_description` naming the target database. - `scope-examples` returns example scope strings for a given `use_case` argument, one of `general`, `ci`, or `exploration`. ## Writing the description The `scope` argument you pass to `seedfast_plan` or `seedfast_run` is plain text, not a query language. Inside an MCP client, your assistant typically drafts that text itself, from the schema `seedfast_schema_info` returns and from whatever project documents it has open, then shows you the wording before anything runs. [Seeding with an AI agent](/docs/ai-agent-seeding) covers the principles behind a good description and walks through one complete run, and [scope examples](/docs/scope-examples) shows four descriptions of increasing size run from the terminal. ## Anti-Patterns to Avoid **Don't Seed in Production Without Explicit Intent** Seedfast writes rows wherever your connection string points, and it has no way to tell a production database from a development one. There is no host allow-list, no environment check, and no confirmation step before a run. Whatever guard you want here, you build on your side. The two that cost nothing are keeping the production connection string out of any environment the agent can read, and gating the CI job on your own branch or environment condition. Narrowing database privileges is worth testing before you rely on it, because a role with reduced grants can fail the inserts outright rather than limiting them. Knowing the blast radius shapes how much guarding is worth building. A run only inserts. It does not drop, truncate or update anything, so a bad scope leaves unwanted rows in a live table for you to clean up. ## Troubleshooting **"npx: command not found"** Node.js isn't installed or not in your PATH. Install Node.js 18+ from [nodejs.org](https://nodejs.org/). **"Not authenticated" or "SEEDFAST_API_KEY not configured" error** Verify your API key is configured in MCP config: 1. Open your MCP config file (see configuration sections above for location) 2. Check that the `env` section contains `SEEDFAST_API_KEY` 3. Verify the key starts with `sfk_live_` 4. Restart your AI assistant to reload the config You can also verify auth status by asking: ``` Run seedfast_doctor to check the installation ``` Expected output should show: `Auth: OK (SEEDFAST_API_KEY configured)` **Claude Desktop doesn't see the server** - Verify JSON syntax in config file - Ensure Claude Desktop was fully restarted (not just minimized) - Check Developer Tools console for errors **Cursor IDE issues** - Verify JSON syntax in `.cursor/mcp.json` - Restart Cursor completely - Check that `SEEDFAST_API_KEY` is in the `env` section **npm package not found** If you see errors about the package not being found, try clearing npm cache: ```bash npm cache clean --force npx -y seedfast@2.6.3 --version ``` Use the same version your config pins, so a success here tells you something about the build you actually run. --- # Seeding a Database With an AI Agent Over MCP > Source: https://seedfa.st/docs/ai-agent-seeding > Last updated: 2026-09-10 An agent with your repository already open can run the seeding job end to end, reading the schema over MCP, drafting a description of the business, running it and reporting the counts. Every figure here comes from one such session, where AI agent database seeding put 1,923 rows into seven empty tables in a little over two minutes. The description was prepared ahead of the run, and a long one is long for a reason, since a stand built to show the engine at full stretch asks more than most real cases do. It is domain knowledge in plain language anyone on the team can read, argue with and change. ## What AI agent database seeding looks like over MCP Claude Code was the client, the server the published npm package `seedfast` 2.6.3, reporting MCP Server Version 1.0.0. The target was one PostgreSQL database holding the schemas `crm` and `billing`, eight tables, no rows, the DDL on [scope examples](/docs/scope-examples). Configuration belongs to the [MCP setup guide](/docs/mcp-setup-guide). Two checks come first. `seedfast_doctor` takes no arguments. ```text CLI Status: OK Version: seedfast 2.6.3 backend 2.0.0 Path: Auth: OK (SEEDFAST_API_KEY configured) Platform: windows/amd64 Go Version: go1.25.1 MCP Server Version: 1.0.0 ``` Only the `Path` line has been changed. `seedfast_connections_test` takes the connection string you would hand the CLI, your own credentials in it, and masks them in what it prints. The password and the database name in the input below stand in for the ones this session used. ```json { "dsn": "postgresql://postgres:***@localhost:5432/app?sslmode=disable" } ``` ```text Connection test successful for: postgresql://*:*@localhost:5432/app?sslmode=disable ``` The description starts from `seedfast_schema_info`, which returns one entry per table with columns, keys and an approximate row count, under its own note that `reltuples may be -1 on never-analyzed tables; approximate only`. One entry of the eight follows, cut to two of its six columns, both foreign keys kept. Without the optional `dsn` the server reads `SEEDFAST_DSN` or `DATABASE_URL` from its environment. ```json { "name": "billing.subscriptions", "columns": [ { "name": "customer_id", "data_type": "bigint", "nullable": false, "default": null }, { "name": "plan_id", "data_type": "bigint", "nullable": false, "default": null } ], "primary_key": [ "id" ], "foreign_keys": [ { "columns": [ "customer_id" ], "references_table": "crm.customers", "references_columns": [ "id" ] }, { "columns": [ "plan_id" ], "references_table": "billing.plans", "references_columns": [ "id" ] } ], "approximate_row_count": -1 }, ``` With the description drafted and read by a human, one call starts the work. ```json { "dsn": "postgresql://postgres:***@localhost:5432/app?sslmode=disable", "scope": "", "idempotencyKey": "docs-mcp-1" } ``` ```text Seeding started Run ID: run_0387e1b9ee27f841 Status: pending Scope: Use seedfast_run_status to check progress. ``` The `Scope` line here and below stands for the description echoed back in full. `seedfast_run` returns before any row is written, and a retry with the same `idempotencyKey` returns this run instead of starting a second. `seedfast_run_status` is safe to call as often as you like. Five polls went out, two at `Tables: 0/0`, one at `0/7` once the table list was known, and a fourth near the end. ```text Run ID: run_0387e1b9ee27f841 Status: running Scope: Started: 2026-09-06T21:49:07+02:00 Progress: Tables: 5/7 (71.4%) Rows: 723 Current: crm.ticket_messages ``` The fifth carried the summary block a finished run ends on. ```text Run ID: run_0387e1b9ee27f841 Status: completed Scope: Started: 2026-09-06T21:49:07+02:00 Completed: 2026-09-06T21:51:16+02:00 Progress: Tables: 7/7 (100.0%) Rows: 1923 Summary: Success: true Total Tables: 7 Succeeded: 7 Failed: 0 Total Rows: 1923 Elapsed: 128318ms ``` Two resources carry the same run for readers who would rather not poll, `seedfast://runs/{runId}/summary` as JSON with state, progress, summary and tables, `seedfast://runs/{runId}/log` as one event per line. States are pending, running, awaiting_input, completed, failed and cancelled. A run with a question moves to `awaiting_input`, the question readable from `seedfast_run_status` or the `seedfast://runs/{runId}/pending_question` resource, and `seedfast_run_answer` replies inside five minutes, `human_answer` true to approve or false with a refinement in `raw`. This run had no question. `seedfast_run_cancel` stops a run still going, and the rows already written stay in place. Underneath, the server runs the command a terminal runs, `seedfast seed --scope ""`, which is why these counts match what the terminal guides measured, [seed your database](/docs/seed-your-database) among them. ## The description is a document, not a syntax Nobody types this out from memory before a demo. An agent drafts it from the schema it just read and the documents the team keeps, then runs it. Plain language rather than a syntax is what matters when a product manager wants the invoice mix changed. Keep the file in the repository beside the migrations, and reread it when the schema changes, so that a new table gets its sentence before the next run. ## What to give the agent Seedfast reads the schema. Everything the schema cannot say has to reach the description, and collecting it is the agent's job. The README and the product docs carry the business. Where free text has to sound like your own, the fixtures and hand-written sample tickets show what it should sound like, and the numbers come from the tests and screens that will read these rows. What the schema alone already supplies is on [data realism](/docs/data-realism). Outside all of that sit the locale the people belong in and the reference values a screen compares against, plans and currencies among them. ## Principles of a good description Seven techniques follow, each with the sentence that applies it and the result. **Name every table you care about.** The opening sentence carried the schemas and the product. > Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies. Seven tables went into the plan. The description names two schemas and three countries, with no address table anywhere in it, and `crm.addresses` was not one of the seven. **Put a number on anything a test or a screen reads**, per table, per parent row, and as a percentage for a share. > Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled. 120 of 120 customers came back holding one subscription, and the statuses split 96 active, 12 past_due and 12 canceled, the 80.0, 10.0 and 10.0 asked for. The same phrasing about messages gave 2 on each of the 120 tickets, and [exact row counts](/docs/exact-row-counts) has the histogram queries. **Describe the business in words.** The content of the rows is drawn from those words, job titles, ticket subjects and plan names included. > job titles are heads of data, analytics leads and engineering managers Four titles came back, 90 contacts under each, and the sentence that asked to leave dates of birth empty left all 360 empty. ```sql SELECT job_title, count(*) FROM crm.contacts GROUP BY job_title ORDER BY count(*) DESC, job_title; ``` ```text job_title | count ---------------------+------- Analytics Lead | 90 Engineering Manager | 90 Head of Analytics | 90 Head of Data | 90 ``` Naming the language the values should be written in works the same way, and [localized test data](/docs/localized-test-data) has the script checks for it. **Give the locale of the people a sentence of its own**, apart from where the companies are based. This description sets the second of the two. > 120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups. Names, phone formats and street lines take an instruction of their own. The scopes that set them are on [localized test data](/docs/localized-test-data). **Lay out time explicitly.** The months, the numbering and the width of the window all belong in the sentence. > Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed. Issue dates landed on the first of each month, 2026-01-01 through 2026-08-01, putting 8 invoices under each of the 120 subscriptions, and the numbers ran INV-2026-0001 to INV-2026-0960, 960 of 960 matching `^INV-2026-\d{4}$`, none due before its issue date. Tickets fell inside their window, 2026-07-08 to the day of the run. **State reference data as exact values.** Prices, currency codes and status vocabularies are what a screen compares against. > Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month. The three plan rows came back at 99.00, 399.00 and 1490.00, with EUR on 960 of 960 invoices. **Size the description to the business.** One sentence is enough for a probe. When a stand has to look complete on every screen, something like the brief below is what it takes, and [scope examples](/docs/scope-examples) puts four sizes through this schema. ## A brief for your agent Paste this into Claude Code or Cursor and fill in the angle brackets, a template rather than a transcript. ```text Seed our development database with Seedfast over MCP. 1. Read the schema with seedfast_schema_info and list the tables you found. 2. Read README.md, docs/, and the fixtures and tests that will read the seeded rows. 3. Draft a seeding description in plain language, with these parts in this order: the business and the slice of time it covers, in two or three sentences; reference data with exact values (plans, prices, currencies, status vocabularies); a row count for every table I care about; counts per parent row where a test reads them, and status shares as percentages; the layout in time (which months, how wide the window, how records are numbered); the locale of the people, their phone numbers and their street addresses. 4. Show me the description and the table list, then wait. Do not seed yet. 5. When I approve, call seedfast_run with the description text as the scope and an idempotencyKey I can retry with. 6. Poll seedfast_run_status. If the state is awaiting_input, read the question and answer it with seedfast_run_answer, checking with me first when the answer would change what I approved. 7. When the state is completed, run the counting queries and report the counts per table, the counts per parent row and the status shares next to the numbers the description asked for. ``` The server also ships two prompts. `seed-production-db` walks an agent through a careful run, taking `scope` and an optional `dsn_description`. `scope-examples` returns example scope strings, its `use_case` argument selecting general, ci or exploration. Pasting that brief works in any client. Claude Code has a shorter route, because Seedfast publishes the same procedure as an agent skill that Claude loads on its own once a conversation turns to seeding, rather than waiting for someone to find the brief and paste it again. A second skill handles the setup and the connection failures. Installing both is one plugin command, or two copy commands out of the npm package, and the [Claude Code skill section of the MCP setup guide](/docs/mcp-setup-guide#claude-code-skill) has each route plus the full text of the file for anyone who would rather write it out by hand. ## From description to rows Here is the description this run was given, word for word. ```text Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies. 120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups. Company names read like real companies in those countries and each customer has its own email domain. Each customer has 2 to 4 contacts with emails on the company domain; job titles are heads of data, analytics leads and engineering managers; leave dates of birth empty. Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month. Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled. Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed. Support tickets are mostly normal priority and were opened in the last 60 days; subjects read like real questions about dashboards, CSV exports and SSO. Each ticket has exactly 2 messages: a customer question followed by an agent reply. ``` The log resource carried 24 events, the plan first and the finish last. ```text {"type":"plan_proposed","ts":"2026-09-06T19:50:21Z","data":{"row_counts":{"billing.invoices":960,"billing.plans":3,"billing.subscriptions":120,"crm.contacts":360,"crm.customers":120,"crm.support_tickets":120,"crm.ticket_messages":240},"tables":["billing.invoices","billing.plans","billing.subscriptions","crm.contacts","crm.customers","crm.support_tickets","crm.ticket_messages"]}} {"type":"run_completed","ts":"2026-09-06T19:51:16Z","data":{"elapsed_ms":128318,"failed_tables":0,"success":true,"success_tables":7,"total_rows":1923,"total_tables":7}} ``` One query counts the eight tables afterwards. ```sql SELECT 'crm.customers' AS t, count(*) FROM crm.customers UNION ALL SELECT 'crm.contacts', count(*) FROM crm.contacts UNION ALL SELECT 'crm.addresses', count(*) FROM crm.addresses UNION ALL SELECT 'crm.support_tickets', count(*) FROM crm.support_tickets UNION ALL SELECT 'crm.ticket_messages', count(*) FROM crm.ticket_messages UNION ALL SELECT 'billing.plans', count(*) FROM billing.plans UNION ALL SELECT 'billing.subscriptions', count(*) FROM billing.subscriptions UNION ALL SELECT 'billing.invoices', count(*) FROM billing.invoices; ``` ```text t | count -----------------------+------- crm.customers | 120 crm.contacts | 360 crm.addresses | 0 crm.support_tickets | 120 crm.ticket_messages | 240 billing.plans | 3 billing.subscriptions | 120 billing.invoices | 960 ``` Each of the seven counts in the `plan_proposed` line is the count its table holds, and each is a figure the description names. The eighth row is `crm.addresses`, the table no sentence mentioned. Shares take one query per column, dividing each count by the table total. ```sql SELECT status, count(*) AS n, round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct FROM billing.invoices GROUP BY status ORDER BY n DESC; ``` ```text status | n | pct ----------+-----+------ paid | 672 | 70.0 overdue | 192 | 20.0 disputed | 96 | 10.0 ``` Over `billing.subscriptions` the same query gives the second split. ```text status | n | pct ----------+----+------ active | 96 | 80.0 past_due | 12 | 10.0 canceled | 12 | 10.0 ``` The plans table is short enough to print whole, and it reads back as the description wrote it. ```sql SELECT code, name, monthly_price, currency, seat_limit FROM billing.plans ORDER BY monthly_price; ``` ```text code | name | monthly_price | currency | seat_limit ------------+---------+---------------+----------+------------ starter_eu | Starter | 99.00 | EUR | 25 growth_eu | Growth | 399.00 | EUR | 100 scale_eu | Scale | 1490.00 | EUR | 500 ``` Six of the 960 invoice rows follow, in order of number. ```sql SELECT number, status, issued_at, due_at, total_amount, currency FROM billing.invoices ORDER BY number LIMIT 6; ``` ```text number | status | issued_at | due_at | total_amount | currency ---------------+--------+------------+------------+--------------+---------- INV-2026-0001 | paid | 2026-01-01 | 2026-02-01 | 399.00 | EUR INV-2026-0002 | paid | 2026-02-01 | 2026-03-01 | 399.00 | EUR INV-2026-0003 | paid | 2026-03-01 | 2026-04-01 | 399.00 | EUR INV-2026-0004 | paid | 2026-04-01 | 2026-05-01 | 399.00 | EUR INV-2026-0005 | paid | 2026-05-01 | 2026-06-01 | 399.00 | EUR INV-2026-0006 | paid | 2026-06-01 | 2026-07-01 | 399.00 | EUR ``` [Scope examples](/docs/scope-examples) ran this description three times from a terminal, with the per parent counts beside their queries. ## At full stretch A description for a stand that has to look complete on every screen runs long, roughly 1,200 words for one demo stand we keep, and most real cases need far less. The description this page ran is under 200 words. The parts arrive in a settled order. - the business and the time it covers, in two or three sentences - reference data with exact values - volumes table by table - statuses and their shares - the order of events in time - the locale of the people and their addresses Reading that list is the review step, and an agent that puts the draft beside the table list from `seedfast_schema_info` gives the decision what it needs. ## The same text everywhere None of this is an MCP artefact. The same file goes into `seedfast seed --scope "$(cat seeding-description.txt)"` at a terminal, which [scoping](/docs/scoping) covers, and into a pipeline step behind an API key, which [CI/CD database seeding](/docs/cicd-database-seeding) covers. If your client is not connected yet, the [MCP setup guide](/docs/mcp-setup-guide) has the config block for Claude Code, Cursor, VS Code, Codex and Claude Desktop. Then ask your agent for a draft against your own schema, and count what lands with the query above. --- # CI/CD Database Seeding > Source: https://seedfa.st/docs/cicd-database-seeding > Last updated: 2026-06-24 ## Key Takeaways - Use `SEEDFAST_API_KEY` environment variable for non-interactive CI/CD authentication - The `--scope` flag accepts natural language descriptions of what to seed - `--output json` enables machine-readable output for pipeline validation - Always run database migrations before seeding - Create separate API keys per environment (dev, staging, production) CI/CD database seeding is generating fresh test data inside the pipeline on every run, from the database schema rather than a copied dump. Seedfast turns test data for a CI/CD pipeline into one step: it reads the live schema, resolves foreign keys, and writes valid rows. That gives you automated test data provisioning with no production data and no seed file to maintain. It builds on the broader [database seeding](/blog/database-seeding) workflow. For the generic GitHub Actions and GitLab CI service-container mechanics — independent of Seedfast — see [Seed a Postgres Test Database in GitHub Actions](/blog/github-actions-seed-postgres-database). ## Why CI/CD database seeding is hard Most teams rely on one of these approaches, and each has drawbacks: **Static SQL dumps** contain real or semi-anonymized production data, drift from live schemas, and require constant maintenance. **Custom seeding scripts** become unmaintainable code that breaks with every schema change. **Shared test databases** create race conditions and test pollution between concurrent runs. There's also a deeper issue with any approach based on production data: even anonymized or masked records start from something real. That creates compliance exposure, because there's always something that needs protecting, auditing, or GDPR-scoping. Seedfast generates data from scratch on every run. Your production records are never involved: - **No production data:** data is synthesized from your schema on every pipeline run, so there's nothing to anonymize or protect - **Schema-adaptive:** Seedfast reads your live schema, so you don't rewrite scripts after migrations - **Isolated per run:** each pipeline job seeds its own database, with no shared state and no race conditions ## Before you begin Make sure you have: - A Seedfast account ([sign up at seedfa.st](https://seedfa.st)) - A PostgreSQL database accessible from your CI/CD environment - Access to configure secrets in your CI/CD platform ## Step 1: Create an API key API keys authenticate the Seedfast CLI in non-interactive environments where browser-based login isn't possible. 1. Log in and open the [Seedfast Dashboard](/dashboard) 2. Click **API Keys** in the left sidebar 3. Click **Create new key** and enter a name (e.g. "GitHub Actions") 4. Copy the key immediately; it won't be shown again Your API key format: `sfk_live_a1b2c3d4e5f6...` > **Tip:** Create separate keys for each environment (dev, staging, production). This enables independent rotation and revocation. ## Step 2: Configure CI/CD secrets Store your API key and database credentials as encrypted secrets in your CI/CD platform. **GitHub Actions:** Go to repository → **Settings → Secrets and variables → Actions** → **New repository secret**: ``` SEEDFAST_API_KEY=sfk_live_... SEEDFAST_DSN=postgres://user:pass@host:5432/db ``` > **Note:** Use `SEEDFAST_DSN` rather than `DATABASE_URL`. Many CI environments already set `DATABASE_URL` for other tools, so using `SEEDFAST_DSN` avoids conflicts. The CLI checks `SEEDFAST_DSN` first, then falls back to `DATABASE_URL`. The value you paste into that secret is a URI, so every reserved character in the password needs percent-encoding before it goes in; [the connection string builder](/tools/postgres-connection-string-builder) produces the encoded form from the raw host, user and password. **GitLab CI:** Go to project → **Settings → CI/CD → Variables**. Mark as **Masked** and **Protected**. **CircleCI:** Go to **Project Settings → Environment Variables**. ## Step 3: Add seeding to your pipeline The simplest way to run Seedfast in CI/CD is via `npx`: ```bash npx seedfast seed --scope "Your scope description" --output json ``` The `--scope` flag describes what data to seed in plain English: ```bash # Seed by schema --scope "HR schema: employees, departments, payroll" # Seed by use case --scope "Customers and orders for checkout flow testing" # Seed with specificity --scope "Single user with 3 pending orders" ``` When `--scope` is provided, Seedfast automatically approves the seeding plan without prompting, which is what makes a fully automated pipeline possible. ## GitHub Actions example ```yaml name: E2E Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: testpass ports: - 5432:5432 steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run db:migrate - name: Seed test data run: npx seedfast seed --scope "Users and orders for E2E tests" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: postgres://postgres:testpass@localhost:5432/postgres - run: npm run test:e2e ``` ## GitLab CI example ```yaml test: stage: test services: - postgres:15 variables: POSTGRES_PASSWORD: testpass SEEDFAST_DSN: postgres://postgres:testpass@postgres:5432/postgres script: - npm ci - npm run db:migrate - npx seedfast seed --scope "Test dataset" --output plain - npm run test ``` ## Output modes The `--output` flag controls output format: | Mode | Use case | |------|----------| | `interactive` | Colors and spinners (default, for local dev) | | `plain` | Timestamped logs (for CI log output) | | `json` | Machine-readable (for script validation) | **Validate results with JSON output:** ```bash RESULT=$(npx seedfast seed --scope "..." --output json) if [ "$(echo $RESULT | jq -r '.success')" != "true" ]; then echo "Seeding failed!" exit 1 fi echo "Seeded $(echo $RESULT | jq -r '.rows') rows" ``` ## Exit codes | Code | Meaning | |------|---------| | 0 | Success | | 1 | Generic error | | 2 | Authentication failed (check API key) | | 3 | Database connection failed (check SEEDFAST_DSN) | | 4 | Quota exceeded | | 5 | Operation cancelled by user | ## Security best practices - **Never commit API keys.** Use CI/CD secret management. - **Separate keys per environment** to enable independent rotation. - **Rotate regularly:** generate a new key, update secrets, verify, then revoke the old key. - **Limit scope:** expose the API key only to the seeding step: ```yaml # Key only in the seeding step, not globally - name: Seed database run: npx seedfast seed --scope "..." env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} ``` ## Troubleshooting **"Authentication failed" in CI but works locally:** Verify the secret name matches exactly. Check for leading/trailing whitespace. Ensure the API key hasn't been revoked. **"Connection refused" to database:** Wait for database health check to pass. Use the correct hostname (`localhost` for GitHub Actions, `postgres` for GitLab CI). Check that the CI runner can reach the database port. **Seeding takes too long in pipeline:** Narrow the scope to only the tables your tests need. A focused scope like "users and orders for checkout tests" is faster than "all tables." ## How do you automate test data provisioning in a CI/CD pipeline? **Run a schema-aware generator as a pipeline step.** Authenticate the Seedfast CLI with `SEEDFAST_API_KEY`, run migrations, then call `seedfast seed --scope "..."`. The generator reads the live schema on that run, resolves foreign keys, and writes valid rows, so a migration shipped in the same branch is picked up automatically. There is no static fixture file tracking the schema and no production dump to anonymize. One data-path detail worth noting for a security review: the schema definition (table and column names, types, constraints) is sent to an AI provider to generate the values. Row data never leaves your database, but if a table or column name is itself sensitive, confirm that path fits your policy. ## What's the difference between test data for a CI/CD pipeline and a copied production dump? A generated dataset is built from the schema and contains no real records, so it carries no production PII. That keeps personal data out of that environment and supports the confidentiality and privacy controls a GDPR or SOC 2 program cares about. (It doesn't remove the environment from audit scope by itself, since scope is the boundary your auditor defines, but it removes the real-data-handling obligations that a production copy would drag in.) A dump, even masked, starts from production and brings those obligations with it. The same generate-don't-copy logic applies to longer-lived environments. See [building a staging environment without copying production](/blog/staging-without-prod-data). The same `seedfast seed` step works for unit, integration, and E2E jobs, including a Python suite whose isolation comes from [pytest database fixtures](/blog/pytest-database-fixtures) rather than a reseed between tests. Point it at each job's own database so runs stay isolated. The pipeline pattern is the same on managed Postgres: see [how to seed a Supabase database](/blog/supabase-db-seed) and [how to seed a Neon database](/blog/seed-neon-database) for the per-branch specifics. When you're comparing generators for a Postgres pipeline, [the best Postgres test data generator](/blog/best-postgres-test-data-generator) guide weighs the options, and [data seeding tools](/blog/data-seeding-tools) covers the wider category including the masking-versus-synthetic tradeoff. For the buyer's case — why generate in the pipeline at all instead of copying production or maintaining a seed file — see [synthetic data for CI/CD](/blog/synthetic-data-ci-cd). CI/CD database seeding with Seedfast takes minutes to set up. Start with one test job, refine your scope, then expand to your full test suite. [Try Seedfast](https://seedfa.st) and start seeding in minutes. ======================================================================== # Section: Blog ======================================================================== # Our Interleaved Backfill Wrote Three Times the WAL > Source: https://seedfa.st/blog/postgres-backfill-large-table > Last updated: 2026-09-26 TypeSafe opened [early access to its Jev model](https://typesafe.ai/blog/introducing-system-one-models-and-jev) on 15 September 2026, pricing input at $0.042 per million tokens and listing output tokens as free, so at our own estimate of 150 input tokens a support ticket, labelling ten million tickets comes to about $63. Storing those labels in new `category` and `confidence` columns is a Postgres backfill on a large table, and when we ran one on a million rows in ten batches, the WHERE clause that picked each batch decided most of what the table paid for it. Range batches at the default fillfactor, with nothing run between them, took the heap from 269.3 MB to 548.3 MB and nearly doubled the three indexes to 70.1 MB. Only 11.5% of the updates were HOT when we repeated them at fillfactor 90 with a VACUUM after every batch, so the indexes still grew to 64.4 MB. Swapping `id between lo and hi` for `id % 10 = r`, and changing nothing else, lifted the HOT share to 96.1% and held the indexes at 36.4 MB against the 35.6 MB they started at. Where range batches wrote 850 MB of WAL, interleaving wrote 455 MB, a lead it kept only until a checkpoint followed every batch and the interleaved run wrote 3 022 MB against 1 025 MB for range batches. ![Heap plus index size before and after each of ten batches in a postgres backfill on a large table, where range batches without VACUUM climb from 305 MB to 618 MB, range batches with VACUUM end at 403 MB, and id % 10 batches with VACUUM stay near 336 MB before finishing at 349 MB](/blog/postgres-backfill-large-table-2.svg) ## Key Takeaways - Ten range batches without VACUUM ended with the same heap as one UPDATE over all million rows, so batching alone did nothing for the heap. - A VACUUM after each range batch kept the heap of a fillfactor 100 table to 306.0 MB, but the indexes still reached 65.0 MB and the WAL came out slightly higher than without it. - Range batches stay mostly non-HOT even at fillfactor 90 because each asks every page it touches for new versions of all its rows at once, where an `id % 10` batch asks for one row in ten. - A hybrid that runs all ten `id % 10` passes inside one id range before moving to the next kept interleaving's HOT share and wrote 544 MB of WAL with a checkpoint after every range. - `ALTER TABLE ... SET (fillfactor = 90)` with no rewrite still gave 86.2% HOT on a table loaded at fillfactor 100, and a rewrite by `VACUUM FULL` on 18.6 or `REPACK (CONCURRENTLY)` on 19 Beta 4 took it to 96.1%. - Nothing was HOT once a partial index named `confidence` in its predicate, and a probability map stored in the row next to the label cost more HOT as `jsonb` than as `real[]`. Each run built a fresh `tickets` table on PostgreSQL 18.6, loaded a million rows in id order, indexed `created_at` and `customer_id` next to the primary key, and only then added `category text` and `confidence real`, which no index covered, though one run's partial index named `confidence` in its predicate. A second pass on PostgreSQL 19 Beta 4 reran the core cases, and its HOT counts matched 18.6 to the row in all of them but one. At load the heap measured 269.3 MB at fillfactor 100 and 300.4 MB at fillfactor 90, with 35.6 MB of indexes. Before the first batch of each run we called `pg_stat_reset()` and issued one `CHECKPOINT`, and with autovacuum off, `checkpoint_timeout` at 3600 s and `max_wal_size` far above anything a run wrote, no other checkpoint happened unless the script asked for one. No model was called in these runs, and a SQL expression stood in for a Jev Choice answer by returning one of twelve category strings plus a confidence between 0.5 and 0.999, since the bytes written back decide what the table pays whichever label a model picks. ```sql create table tickets ( id bigint generated always as identity primary key, created_at timestamptz not null, customer_id int not null, subject text not null, body text not null ) with (fillfactor = 90); -- 100 in the baseline runs insert into tickets (created_at, customer_id, subject, body) select now() - (g % 700) * interval '1 hour', ((g::bigint * 7919) % 50000)::int, 'Ticket ' || g || ' about ' || (array['billing','login','export','api','invoice','sso'])[1 + g % 6], repeat(md5(g::text), 6) from generate_series(1, 1000000) g; create index tickets_created_at_idx on tickets (created_at); create index tickets_customer_id_idx on tickets (customer_id); alter table tickets add column category text, add column confidence real; vacuum (analyze) tickets; ``` Seven of the 18.6 runs carry most of this article, and their heap, index and WAL figures below were read after the last batch. | Batch order | Fillfactor | HOT % | Heap MB | Indexes MB | WAL MB | |---|---|---|---|---|---| | Range, no VACUUM | 100 | 0.0 | 548.3 | 70.1 | 876.1 | | Range, VACUUM after each batch | 100 | 0.0 | 306.0 | 65.0 | 897.4 | | Range, VACUUM after each batch | 90 | 11.5 | 338.9 | 64.4 | 850.2 | | `id % 10`, VACUUM after each batch | 90 | 96.1 | 312.4 | 36.4 | 454.6 | | Range, VACUUM and CHECKPOINT after each batch | 90 | 11.5 | 338.9 | 64.4 | 1 025.3 | | `id % 10`, VACUUM and CHECKPOINT after each batch | 90 | 96.1 | 312.4 | 36.4 | 3 022.3 | | Hybrid (`id % 10` inside each id range), VACUUM after each pass, CHECKPOINT after each range | 90 | 96.1 | 312.4 | 35.8 | 544.0 | ## Why a Postgres backfill doubles a large table An ordinary UPDATE never changes a row where it sits. Postgres writes a complete new version of the row, leaves the old one in place until VACUUM, or the pruning that runs when a later query visits the page, can show that no snapshot still needs it, and then has to make the indexes find the new version, which normally costs one new entry in every index even when no indexed value changed. For our three-index table, labelling every row means a new heap tuple per row and three index entries to go with each, unless the update qualifies as HOT. Whether an update is a heap-only tuple update gets settled inside `heap_update` in [heapam.c](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/access/heap/heapam.c), and the first thing it checks is whether the new version fits in the free space of the page that holds the old one. ```c pagefree = PageGetHeapFreeSpace(page); newtupsize = MAXALIGN(newtup->t_len); if (need_toast || newtupsize > pagefree) ``` A version that fails this test is written to another page, and a version on another page cannot be HOT. Past the test, the code reaches a branch commented "Since the new tuple is going into the same page, we might be able to do a HOT update. Check if any of the index columns have been changed." It sets `use_hot_update` only when `bms_overlap(modified_attrs, hot_attrs)` comes back false, meaning that none of the columns the UPDATE changed is one an index depends on. Neither `category` nor `confidence` is indexed here, so the second condition held in every run except the one designed to break it, and everything came down to free space on the page. Loaded at fillfactor 100, a table has almost none, because the load packs each page as full as it will go. Of the 1 000 000 updates in the range run without VACUUM, 58 found room on their own page, and every other new version went to a different page and added an entry to each of the three indexes on its way. ``` heap | indexes | wal --------+---------+-------- 548 MB | 70 MB | 876 MB (1 row) ``` The heap in that output holds a live and a dead copy of every row. Splitting the work into ten statements did nothing for the heap on its own, since nothing between the batches reclaimed the dead versions, and one UPDATE over all million rows landed on the same heap size with 74.8 MB of indexes and zero HOT updates. ## VACUUM between range batches Batching exists mostly for the lock side of a backfill, keeping each transaction short so that [writers are not left queueing behind row locks on the whole table](/blog/migration-testing), and it usually comes with advice to VACUUM between batches, which our runs bore out for the heap. A plain `VACUUM tickets` after every range batch at fillfactor 100 kept the heap to 306.0 MB. After the first batch, each batch's new versions went into the space the previous batch's dead versions had left, which VACUUM had just handed back to the free space map, so the heap hardly grew from there on. HOT finished at the same 58 updates as the run with no VACUUM at all, because the space VACUUM frees sits on pages whose rows are already labelled, while the next range batch rewrites rows on pages that are still packed. Those new versions landed in the freed space as ordinary updates, each one writing an entry into every index. As a result the indexes still ended at 65.0 MB, keeping most of the growth the run without VACUUM showed. Although VACUUM removes dead entries from a B-tree, the leaf pages that split to make room for the new ones stay allocated, and a plain VACUUM does not shrink a B-tree index file, so the freed space waits inside the index for later entries whose keys belong on those pages. Nor did the WAL come down, since the VACUUM passes write records of their own and pushed the total slightly past the run without them. The middle line of the chart is the same strategy at fillfactor 90, climbing a few megabytes per batch after its first-batch jump, mostly in the indexes. ## What a 10% reserve does for range batches Setting fillfactor below 100 is the standard answer to updates that cannot stay on their page, since inserts then leave part of each page empty for later versions of the rows already on it. A table at 90 starts bigger than one at 100, and settings below 90, which we did not measure, buy their extra room the same way, by making the whole table larger before the first label is written. The range batch that ran against the fillfactor 90 table used the label expression every run shared. ```sql update tickets set category = (array['billing','login','export','api','invoice','sso', 'refund','bug','feature','security','outage','other']) [1 + abs(hashtext(subject)) % 12], confidence = (0.5 + (abs(hashtext(body)) % 500) / 1000.0)::real where id between :lo and :hi; -- 100 000 ids per batch vacuum tickets; ``` Of the 1 000 000 updates, 115 059 (11.5%) were HOT. Heap and indexes grew about as much as in the fillfactor 100 run with the same VACUUM, as the second and third rows of the table show. Because every row on a page falls inside the same id range, one range statement asks the page to hold new versions for all of its rows at once, and a reserve of a tenth of the page has room for only a few of them, which become the HOT updates. Beyond those few, the rows leave the page exactly as they would at fillfactor 100, each adding an entry to every index, and the VACUUM that follows frees their old space too late to matter, because the next range lives on other pages. ## Interleaving the batches with `id % 10` Interleaving asks less of each page at a time. Keep the same SET list, replace the range with a remainder, run the statement once for each value of `r` from 0 to 9, and VACUUM after each run. Since VACUUM refuses to run inside a transaction block, no function or DO block can drive the loop, which leaves psql or a small script. ```sql update tickets set category = (array['billing','login','export','api','invoice','sso', 'refund','bug','feature','security','outage','other']) [1 + abs(hashtext(subject)) % 12], confidence = (0.5 + (abs(hashtext(body)) % 500) / 1000.0)::real where id % 10 = :r; vacuum tickets; ``` Because the table was loaded in id order, a page holds a run of consecutive ids, and each remainder takes one row in ten from every page. That tenth mostly fits in the reserve, and by the time the next remainder comes around, VACUUM has freed the old versions of the last one, so the room is back on the same page. We did not measure a table whose rows have drifted out of id order, where each id range is already scattered across many pages and range batches should hurt less. On 18.6, 961 194 of the 1 000 000 updates were HOT, and the fourth row of the table shows heap and indexes barely moving while the WAL came to a little over half of what range batches wrote. In the chart, the `id % 10` line holds at 336 MB for the first six batches before ending at 349 MB. At fillfactor 100 the same interleaving reached only 20.6% HOT and let the indexes grow to 52.5 MB, so the reserve matters even though VACUUM opens a gap on every page after the first batch, for a reason in hio.c that the section on existing tables comes back to. This run is also the one where 19 Beta 4 disagreed with 18.6, counting 206 449 HOT updates to 18.6's 206 441. ### What n_tup_hot_upd tells you early The HOT share lives in `pg_stat_user_tables`, and the time to read it is after the first batch, while a change of plan still costs little. ```sql select n_tup_upd, n_tup_hot_upd, round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) hot_pct, n_dead_tup, n_live_tup from pg_stat_user_tables where relname = 'tickets'; ``` After the interleaved run at fillfactor 90 it returned this. ``` n_tup_upd | n_tup_hot_upd | hot_pct | n_dead_tup | n_live_tup -----------+---------------+---------+------------+------------ 1000000 | 961194 | 96.1 | 0 | 1000075 (1 row) ``` For the range batches on an identical table, the same query came back with a different second column. ``` n_tup_upd | n_tup_hot_upd | hot_pct | n_dead_tup | n_live_tup -----------+---------------+---------+------------+------------ 1000000 | 115059 | 11.5 | 0 | 683916 (1 row) ``` Leave `n_live_tup` out of any conclusion, since it is an estimate and the `pg_stat_reset()` at the start of each run zeroed it. One batch was enough to tell the two runs apart, and on the interleaved run the ratio read between batches never dropped below the final 96.1%. Because `pg_stat_reset()` clears the counters for the whole database, a shared server calls for `pg_stat_reset_single_table_counters('tickets'::regclass)`, which resets only this table, or for recording `n_tup_upd` and `n_tup_hot_upd` before the first batch and subtracting afterwards, and size and WAL are measured the same way once the WAL position is saved up front. ```sql create table _lsn as select pg_current_wal_lsn() l; -- before the first batch select pg_size_pretty(pg_relation_size('tickets')) heap, pg_size_pretty(pg_indexes_size('tickets')) indexes, pg_size_pretty(pg_current_wal_lsn() - (select l from _lsn)) wal; ``` ``` heap | indexes | wal --------+---------+-------- 312 MB | 36 MB | 455 MB (1 row) ``` ## could not extend file: No space left on device To see what the doubling does on a small disk, we put the fillfactor 90 table and all of its indexes into a tablespace on a 420 MB tmpfs, which was 81% full after the load with 337M used and 84M available, and then ran the backfill as a single UPDATE over every row. ``` psql:/tmp/disk.sql:24: ERROR: could not extend file "pg_tblspc/16384/PG_18_202506291/5/16386": No space left on device HINT: Check free disk space. Time: 4289.510 ms (00:04.290) ``` The statement rolled back and labelled nothing, yet it left the filesystem at 100% used, and a VACUUM afterwards got it back only to 355M, which is still more than the 337M it held before the attempt. On a fresh container with the same tablespace, all ten `id % 10` batches finished, with a VACUUM after each. ``` Filesystem Size Used Avail Use% Mounted on tmpfs 420M 349M 72M 84% /mnt/small select count(*) filter (where category is not null) labelled, pg_size_pretty(pg_relation_size('tickets')) heap, pg_size_pretty(pg_indexes_size('tickets')) indexes from tickets labelled | heap | indexes ----------+--------+--------- 1000000 | 312 MB | 36 MB (1 row) ``` Each VACUUM had handed the space of the old versions back before the next batch needed it, so the run never asked the disk for room for a second copy of the table, and its `n_tup_hot_upd` came to the same 961 194 as the interleaved run on a filesystem with room to spare. ## Checkpoints in the middle of a backfill ![WAL written by each batch order with one checkpoint before the run and with a checkpoint after every 100 000 rows, 850 MB against 1 025 MB for id ranges, 455 MB against 3 022 MB for id % 10 batches, and 455 MB against 544 MB for the hybrid](/blog/postgres-backfill-large-table-3.svg) Our runs so far had one checkpoint each, issued before the first batch, but a backfill that runs for hours on a production server sees many more, because checkpoints arrive on a timer and whenever WAL passes `max_wal_size`. To measure what they cost, we added to the comparison a hybrid that walks ten ranges of 100 000 ids and runs all ten remainders inside each range, with a VACUUM after each pass, before moving on, and then reran all three batch orders with a `CHECKPOINT` after every 100 000 rows, which for the hybrid meant one per range. Under those checkpoints the WAL of range batches rose only moderately, to 1 025 MB from 850 MB. The interleaved run nearly tripled the range figure at 3 022 MB, even though its HOT count stayed at 961 194 and the table and its indexes ended at the same sizes as before. With `full_page_writes` on, as it is by default and was here, the first change to any page after a checkpoint writes a full-page image of it into the WAL, and an interleaved batch changes every heap page because every page holds rows of every remainder, so each checkpoint set up a fresh image of the entire heap for the next batch to write. Range batches paid correspondingly less for the same checkpoints, since each one changes its own tenth of the heap plus the pages its moved rows land on. Turning on `wal_compression = lz4` in the session that ran the updates, which takes superuser or a granted SET privilege, shrank those images without closing the gap, leaving range batches at 753 MB and the interleaved run at 1 011 MB. A checkpoint after every batch is a harsh schedule, and where a real run lands between the two halves of the chart depends on how many checkpoints its batches span. ### A hybrid that interleaves one range at a time Confining the interleaving to one slice of the table at a time is what keeps the hybrid cheap under checkpoints, because after a checkpoint between ranges only the pages of the next range, plus the few that moved rows land on, need a fresh image. ```sql update tickets set category = (array['billing','login','export','api','invoice','sso', 'refund','bug','feature','security','outage','other']) [1 + abs(hashtext(subject)) % 12], confidence = (0.5 + (abs(hashtext(body)) % 500) / 1000.0)::real where id between :lo and :hi and id % 10 = :r; vacuum tickets; -- ten passes per range, one per remainder; after the tenth, move :lo and :hi on ``` The hybrid wrote 455.4 MB of WAL without mid-run checkpoints, about as much as plain interleaving did, and a checkpoint after every range raised that only to 544 MB, with 961 198 HOT updates that round to the same 96.1%. By wall clock it was the slowest order on 18.6, taking 71.3 s where plain interleaving took 28.1 s and range batches 31.3 s. The figures are loose, since each includes eleven 0.7 s measurement pauses, one at the start and one after every 100 000 rows. Those pauses are identical across the three runs, so the gap comes from the hybrid issuing a hundred UPDATE and VACUUM pairs where the other two issue ten, and with ten times the UPDATEs as well as ten times the VACUUMs we would not put all of it down to VACUUM. We would still run the hybrid for a long backfill on a live table, with each range small enough to finish well inside one checkpoint interval, although we measured it on one table shape only, so a table with wider rows or more indexes should have its `n_tup_hot_upd` read after the first range before anyone commits a night of batches to it. ## Fillfactor on a table that already exists A table created without a fillfactor setting carries the default of 100, and `ALTER TABLE ... SET (fillfactor = 90)` changes the storage parameter while leaving every existing page as it was. The ALTER alone, which we expected to do nothing for a backfill, still gave the same interleaved batches 86.2% HOT (861 784 updates) on a table loaded at fillfactor 100, with the indexes at 39.8 MB and 491.8 MB of WAL. With every page still packed, almost none of the first batch's updates were HOT, which accounts for the missing tenth, and the VACUUM after it opened a gap of about one row in ten on every old page. What happened next was decided in [hio.c](https://github.com/postgres/postgres/blob/REL_18_STABLE/src/backend/access/heap/hio.c), where `RelationGetBufferForTuple` chooses a page for a tuple that cannot stay where it was. ```c /* Compute desired extra freespace due to fillfactor option */ saveFreeSpace = RelationGetTargetPageFreeSpace(relation, HEAP_DEFAULT_FILLFACTOR); ... targetFreeSpace = len + saveFreeSpace; ``` Our reading of the two files together is that a tuple moving to another page needs its own length plus the fillfactor reserve free on the target page, while a tuple staying on its own page is compared only with the page's free space. That rule keeps rows arriving from elsewhere out of the small gaps VACUUM opened on a table whose target is 90, so the gaps stay free for HOT updates of the rows that live on those pages. Leave the target at 100 and the reserve is zero, so a row that leaves its page may drop into any gap that fits it, including gaps on pages the batch has not reached yet, and we take that to explain the 20.6% interleaving managed on a table created at 100. On 18.6, a `VACUUM FULL` after the ALTER repacked every page at 90, and the backfill that followed came out at the same 961 194 HOT updates as a table created at 90, at the price of an ACCESS EXCLUSIVE lock held through the whole rewrite. PostgreSQL 19 adds `REPACK`, and on 19 Beta 4 we ran its concurrent form after the same ALTER. ``` => repack (concurrently) tickets REPACK ``` Run after it, the backfill also reached 961 194 HOT updates, with 434.2 MB of WAL. Beta 4 came out on 24 September 2026 and several features were reverted during this beta cycle, so treat the result as a preview of a command that may still change before general availability, which is targeted for October 2026. According to the [REPACK documentation](https://www.postgresql.org/docs/19/sql-repack.html), the command rewrites the table "into a new disk file with no extra space (except for the space guaranteed by the fillfactor storage parameter)", the step the ALTER alone leaves undone. With `CONCURRENTLY` it takes the ACCESS EXCLUSIVE lock only to swap the table and index files, capturing the changes made during the copy through logical decoding. Unlike plain `REPACK`, the concurrent form cannot be used on a table that lacks a primary key and index-based replica identity, and either form needs free disk space at least equal to the table plus its indexes. Its warning that `REPACK` with `CONCURRENTLY` is not MVCC-safe points to Section 13.6, which explains that once the rewrite commits, a transaction whose snapshot predates the commit, and which had not touched the table before `REPACK` started, sees the table as empty. In a long report that reads several tables in one snapshot, `tickets` can therefore come back empty beside tables that are not. Transactions that had already read the table are spared, because the lock they hold makes the file swap wait until they end. ## What switches HOT off partway through a backfill Some additions that look harmless took HOT away from runs otherwise set up to keep it, and a review-queue index is the easiest to add without noticing. Built before the backfill so that low-confidence rows can be found as soon as they are labelled, it is one line. ```sql create index tickets_review_idx on tickets (id) where confidence < 0.6; ``` With that index in place, the interleaved run at fillfactor 90 recorded no HOT updates at all. ``` n_tup_upd | n_tup_hot_upd | hot_pct | n_dead_tup | n_live_tup -----------+---------------+---------+------------+------------ 1000000 | 0 | 0.0 | 0 | 1000364 (1 row) heap | indexes | wal --------+---------+-------- 309 MB | 61 MB | 690 MB (1 row) ``` The index is on `id` and holds nothing while every `confidence` is NULL, yet it was enough, because Postgres counts a column named in an index predicate as an indexed column when it decides whether an update can be HOT, and the backfill changes `confidence` on every row. Together the three regular indexes and the review index came to 61.4 MB, where the same run without it finished at 36.4 MB. Building the review index after the last batch, with `CREATE INDEX CONCURRENTLY` if the table is taking writes, keeps it out of the way. Work queues can bring the range problem back without anyone writing a range. Claiming batches with `FOR UPDATE SKIP LOCKED` is a common way to spread a backfill over workers, and we ran the claim from one session, in id order, at fillfactor 90 with a VACUUM after each batch. ```sql with batch as ( select id from tickets where category is null order by id limit 100000 for update skip locked) update tickets t set category = (array['billing','login','export','api','invoice','sso', 'refund','bug','feature','security','outage','other']) [1 + abs(hashtext(subject)) % 12], confidence = (0.5 + (abs(hashtext(body)) % 500) / 1000.0)::real from batch where t.id = batch.id; ``` It matched the range batches to the row at 115 059 HOT updates, because `order by id limit 100000` hands out a contiguous block of ids, and on this table a contiguous block of ids is a contiguous block of pages. Interleaving would survive a queue only if the claim itself interleaved, for instance by adding the remainder to its WHERE clause, which is one variant we have not run. Keeping the model's whole answer in the row costs HOT in a different way. A Choice answer carries a probability for every option, and storing that vector next to the label makes each new version larger than the reserve was sized for. Held in a `real[]` column, the twelve values still left 77.7% of the updates HOT, though the heap grew to 387.6 MB. A `jsonb` object keyed by label does far worse, because it repeats every key name in every row, and storing the same numbers that way halved the HOT share to 50.0% while the heap doubled from 300.4 MB to 601.0 MB. With the label and confidence written into `tickets` and the probabilities inserted into `ticket_probs (id bigint primary key, probs real[] not null)` alongside each batch, the HOT share came back, and `tickets` stayed at 96.1% while the side table took 134.2 MB. ## Labels in a table of their own The cheapest backfill we ran left the big table untouched and wrote the labels somewhere else. ```sql create table labels (id bigint primary key, category text not null, confidence real not null); insert into labels select id, (array['billing','login','export','api','invoice','sso', 'refund','bug','feature','security','outage','other']) [1 + abs(hashtext(subject)) % 12], (0.5 + (abs(hashtext(body)) % 500) / 1000.0)::real from tickets where id between :lo and :hi; ``` While the side table grew to 69.5 MB, `tickets` kept its 269.3 MB of heap and 35.6 MB of indexes, and no other strategy we tried wrote less WAL than this run's 138.0 MB. The price is a join on every read that wants a label, plus a delete path on `tickets` that has to reach `labels` as well. For a copy of the table with the labels already filled in, an `insert ... select` into a new `tickets_new`, followed by the primary key and both indexes, wrote 330.7 MB of WAL and produced 314.7 MB of table and indexes while the old heap and indexes stayed on disk until dropped. Writes that reach the old table during the copy have to be blocked or replayed before a rename swaps the two, and the rename itself takes an ACCESS EXCLUSIVE lock. ## Frequently asked questions ### Why did my table double in size after an UPDATE? Postgres writes a new version of every row an UPDATE touches and keeps the old version until VACUUM removes it, so updating every row with no VACUUM in between needs room for two copies of the table. On pages packed at load, as they are at the default fillfactor of 100, the new versions also move to other pages and add an entry to every index, which is how 269.3 MB of heap became 548.3 MB on our million-row table. ### Does lowering fillfactor make updates HOT? On a fillfactor 90 table with a VACUUM after each batch, range batches were only 11.5% HOT because each one rewrote every row on its pages, and `id % 10` batches reached 96.1%. Lowering fillfactor pays off when each page has to hold only a few new versions at a time and the update changes no indexed column, and even an ALTER without a rewrite gave an existing fillfactor 100 table a HOT share of 86.2%. ### How do I check the HOT update ratio? After the first batch, divide `n_tup_hot_upd` by `n_tup_upd` from `pg_stat_user_tables`, using the query in the interleaving section. Both counters are cumulative, so on a shared server subtract the values you noted before the backfill, or start it from `pg_stat_reset_single_table_counters('tickets'::regclass)` in place of a database-wide `pg_stat_reset()`. ### Should I store model probabilities in jsonb? Keep them out of the row being backfilled. With the full distribution in a side table the main table stayed at 96.1% HOT, whereas the same twelve numbers stored in the row brought HOT down to 77.7% as a `real[]` (heap 387.6 MB) and to 50.0% as a 12-key `jsonb` map, whose repeated key names grew the heap to 601.0 MB. ## Related guides - For the locks a batched backfill holds and the writers it keeps waiting, see [migration testing at production volume](/blog/migration-testing). - [What pg_stat_statements forgets](/blog/pg-stat-statements-missing-queries) measures which statements that view silently drops. - Before switching triggers and foreign keys off for a backfill, read [what replica mode does not switch off](/blog/session-replication-role). - Copying data out and back in also rewrites a table, and [the dump that breaks its own restore](/blog/pg-dump-data-only-restore) covers where that goes wrong. --- # Forty Deletes Vanished From pg_stat_statements > Source: https://seedfa.st/blog/pg-stat-statements-missing-queries > Last updated: 2026-09-22 On the server this article was written against, `pg_stat_statements_info.dealloc` reads 20, and that counter is all the database has left to say about forty rows an agent deleted. The forty entries were there while the `DELETE` statements ran, one per table, counted and timed like anything else. Then the same agent read the schema twice over, 12 800 statements that changed not one row, and the reading needed room. The eviction is documented behaviour rather than a defect. The view is sized once, at 5 000 entries by default, and once more distinct statements arrive than that the documentation says "information about the least-executed statements is discarded". Run once, a statement is the least-executed thing in the database. ![Forty one-off DELETE statements recorded in pg_stat_statements, then 12 800 read statements from the same session, and afterwards zero of the forty remain while dealloc reads 20](/blog/pg-stat-statements-missing-queries-2.svg) Ordinary application traffic sits at the far end of the axis that policy implies, since an application issues a small set of statements a great many times each. An agent writing its SQL fresh on every turn does the reverse, and the rewriting adds a second cost on top, because a question asked in two shapes occupies two entries and each of those two has been called once. ## Key Takeaways - Twelve ways of asking one question produced eight entries. Lowercase keywords, reflowed whitespace, a leading `--` comment and a `public.` prefix all merged into the baseline, while `count(1)`, a table alias, a swapped predicate order, a subquery and a CTE each took an entry of their own. - Two tenant schemas whose same-named tables hold 100 and 100 000 rows share one `queryid` on PostgreSQL 18, where 17 kept them apart, and the surviving entry carries the name of whichever schema was queried first. - Nothing in the view names the caller beyond its role. Two connections under one role, one with `application_name` set to `payments-api` and one to `claude-agent`, landed in the same entry, so an agent borrowing the application's login cannot be separated from it after the fact. - With `track` at its default `top`, a statement inside a PL/pgSQL function or a `DO` block leaves no entry of its own, and the same holds for four statements that raised errors. - Flooding the view past its ceiling raised `dealloc` while the application's own entries stayed, both a 500-call hot query and a report that had run once. The flood took the forty one-off writes instead, reaching them because they named forty different tables. - Where the view cannot answer, the log can, since `log_line_prefix` carrying `%u` and `%a` together with `log_min_duration_statement` records the role, the `application_name` and the literal values. One container produced every number here, PostgreSQL 18.6 built on 22 September 2026, with 17.11 and 19beta3 re-running each script wherever the version changed the answer. ## One question, eight entries in the view Constants are stripped before the identifier is computed, so three calls carrying three different customer ids arrive as one entry, which disposes of literal-heavy SQL as a cause of near-duplicates. ``` SELECT count(*) FROM orders WHERE customer_id = 42; SELECT count(*) FROM orders WHERE customer_id = 77; SELECT count(*) FROM orders WHERE customer_id = 313; calls | query -------+---------------------------------------------------- 3 | SELECT count(*) FROM orders WHERE customer_id = $1 (1 row) ``` What splits an entry is any change to the parse tree, and a model rewriting its own SQL changes that tree far more often than a person editing a query by hand. Twelve shapes of one question, "how many orders does customer 42 have", produced eight entries. | How the question was written | Result | | --- | --- | | `SELECT count(*) FROM orders WHERE customer_id = 42` | the baseline entry | | the same statement in lowercase keywords | merged | | the same statement reflowed across three lines | merged | | the same statement under a leading `--` comment | merged | | `FROM public.orders` in place of `FROM orders` | merged | | `SELECT count(1)` in place of `count(*)` | its own entry | | `FROM orders o WHERE o.customer_id = 42` | its own entry | | `WHERE customer_id >= 42 AND customer_id <= 42` | its own entry | | `WHERE status = 'new' AND customer_id = 42` | its own entry | | `WHERE customer_id = 42 AND status = 'new'` | its own entry | | `FROM (SELECT id FROM orders WHERE customer_id = 42) s` | its own entry | | `WITH c AS (SELECT * FROM orders WHERE customer_id = 42) SELECT count(*) FROM c` | its own entry | Read the merged half and the machine looks forgiving, since everything a formatter or a linter would touch comes back as one row. The cost lives in the split half, and the instructive pair is the two predicate orders, adjacent rows in the table above and identical in every respect a reader would care about. Because swapping two conditions joined by `AND` changes the tree, Postgres computes a different `queryid` for each order. The trees really do differ, so the implementation is within its rights. The consequence lands on whoever reads the view later, since an agent with no reason to emit its predicates in a stable order spreads one piece of work across rows that nothing marks as related. Lists of constants changed hands in PostgreSQL 18, and the change merges more than it splits. Two statements that differ only in how many ids they look up now share an entry, with a comment standing in for the elements that were dropped. ``` SELECT count(*) FROM orders WHERE id IN (1,2,3); SELECT count(*) FROM orders WHERE id IN (1,2,3,4,5,6,7); SELECT count(*) FROM orders WHERE id IN (900); calls | query -------+--------------------------------------------------------- 2 | SELECT count(*) FROM orders WHERE id IN ($1 /*, ... */) 1 | SELECT count(*) FROM orders WHERE id IN ($1) (2 rows) ``` The release note behind it reads "Have query id computation of constant lists consider only the first and last constants", and on 17.11 the same three statements produce three rows, `IN ($1)`, `IN ($1,$2,$3)` and `IN ($1,$2,$3,$4,$5,$6,$7)`. Note the leftover in the 18 output, though, because a single-element list is not treated as a list at all and keeps a row to itself. ## Two tenants, one queryid A schema per tenant is common enough, and the tables inside those schemas usually share their names. Two of them here, `tenant_a.invoices` with 100 rows and two columns, `tenant_b.invoices` with 100 000 rows and three, were each counted once under their own `search_path`. ``` SET search_path = tenant_a; SELECT count(*) FROM invoices WHERE id > 5; SET search_path = tenant_b; SELECT count(*) FROM invoices WHERE id > 5; queryid | calls | rows | query ---------------------+-------+------+--------------------------------------------- 9114093936963880505 | 2 | 2 | SELECT count(*) FROM invoices WHERE id > $1 (1 row) ``` One entry now carries both calls, so a 100-row table and a 100 000-row table have their timings averaged together inside it. Naming the schemas explicitly does not separate them either, and the entry that comes back then reads `SELECT count(*) FROM tenant_a.invoices WHERE id > $1` against the same `queryid`, which is the part to be careful about when reading a dashboard, since that text is a copy of whichever statement created the entry rather than a description of the statements now being counted in it. With the `tenant_b` query run first, the row carries the label `tenant_b.invoices` while still counting both. PostgreSQL 17.11 answered the same script with two entries, `queryid 27677043242712635` and `5622856266958512818`, and naming the schemas explicitly there gave each of them its own correct text. The merge arrived in 18 as "Adjust query id computations to group together queries using the same relation name", whose note adds that this holds "even if the tables in different schemas have different column names". On 19beta3 the behaviour is the same and the number is different again, `-6590361220937067171`, so a major upgrade also starts this history from nothing. ![Two tenant schemas with same-named invoices tables of 100 and 100 000 rows, kept as two pg_stat_statements entries on PostgreSQL 17 and merged into one entry on 18 and 19 labelled with whichever schema ran first](/blog/pg-stat-statements-missing-queries-3.svg) The same release took away the other handle on tenancy. Setting a tenant key used to leave its value in the view, one entry per distinct value, and the value is now a placeholder. ``` SET app.tenant = 'alpha'; SET app.tenant = 'beta'; SET statement_timeout = '3s'; SET statement_timeout = '9s'; RESET statement_timeout; calls | query -------+------------------------- 1 | RESET statement_timeout 2 | SET app.tenant = $1 2 | SET statement_timeout = $1 (3 rows) ``` On 17.11 those two `app.tenant` lines produce `SET app.tenant = 'alpha'` and `SET app.tenant = 'beta'` as separate rows, which is how an operator could once tell from statistics alone which tenants a pooled worker had served. A busy multi-tenant server no longer burns thousands of entries on per-tenant noise. Both mechanisms took the tenant out of the answer at the same time, the schema name and the `SET` value together, so an operator reading statistics alone has lost the handle 17 gave them. ## The view has no application_name column Of the fifty-two columns in the view, the grouping key is the first four, `userid`, `dbid`, `toplevel` and `queryid`, and nothing among the remaining forty-eight identifies a connection, a session, a client address or an `application_name`. Put two connections under the role `app`, one announcing itself as `payments-api` and the other as `claude-agent`, and an identical statement from each lands in one row, while the role `agent` running the very same text opens a second entry. ``` grouped_by_role | calls | query -----------------+-------+---------------------------------------------------- agent | 1 | SELECT count(*) FROM orders WHERE customer_id = $1 app | 2 | SELECT count(*) FROM orders WHERE customer_id = $1 (2 rows) ``` Role is the only axis of separation the view offers, which has a blunt consequence for any deployment where the agent reuses the application's connection string. Because the numbers were added together the moment each statement finished, with no breakdown kept, nothing splits those two callers apart afterwards. The role has to exist before the traffic does, since an agent that borrowed the application's login is inside the application's numbers permanently. ### `` Reading the view from a role without `pg_read_all_stats` is a different experience from reading it as superuser, since the rows other roles produced are all there with their call counts, and their text is redacted. ``` rolname | calls | query ----------+-------+-------------------------- agent | 1 | postgres | 1 | (2 rows) ``` Granting `pg_read_all_stats` restores the text. That grant is the one usually handed to a monitoring role without much thought, and on a server where an agent shares a database with an application it also hands over the text of every statement the application runs. ## The statement inside the function never reaches the view `pg_stat_statements.track` defaults to `top`, which records what the client sent and nothing the server reached on its own behalf. Wrap the work in a function and the view keeps only the call. ``` SELECT orders_for(42); DO $$ DECLARE n bigint; BEGIN SELECT count(*) INTO n FROM orders WHERE customer_id = 43; END $$; toplevel | calls | text ----------+-------+---------------------------------------------------------------- t | 1 | DO $$ DECLARE n bigint; BEGIN SELECT count(*) INTO n FROM orde t | 1 | SELECT orders_for($1) t | 1 | SELECT pg_stat_statements_reset() (3 rows) ``` The `SELECT count(*) INTO n FROM orders WHERE customer_id = cid` inside that function body is absent, so the table it touched never appears. The `DO` block keeps its entire text as one utility statement and hides its contents the same way, which matters more than it used to now that a model asked to do several things in one round trip will wrap them in `DO $$ ... $$`. Setting `track` to `all` brings the inner statements back as rows carrying `toplevel` false, two extra rows for the two wrapped statements here, and the one from inside the function reads `WHERE customer_id = cid` where the one from the `DO` block reads `= $1`, since a PL/pgSQL variable is not a constant for the normalizer to replace. Nested statements then compete for the same 5 000 slots as everything else. ## The statements that failed were never counted Four statements were run in one session, each of them a plausible thing for a model to emit against a schema it has partly misremembered. ``` SELECT count(*) FROM orders WHERE customer_id = 'not-an-int'; ERROR: invalid input syntax for type integer: "not-an-int" SELECT count(*) FROM no_such_table WHERE x = 1; ERROR: relation "no_such_table" does not exist SELECT 1 / 0; ERROR: division by zero INSERT INTO orders(id, customer_id, amount, status) VALUES (1, 1, 1, 'dup'); ERROR: duplicate key value violates unique constraint "orders_pkey" DETAIL: Key (id)=(1) already exists. ``` Afterwards the view held two rows, one for the single statement in that session that succeeded and one for the reset that had opened it. Recording happens when a statement finishes executing, and none of these finished. For an operator who arrives after an incident wanting to know what the agent tried, the view is silent by construction. ## `pg_stat_statements.max`, dealloc and the forty deletes The ceiling is a shared memory allocation fixed at startup, and because `pg_stat_statements.max` carries `postmaster` context, changing it means a restart that a reload cannot substitute for. To work out how close a real agent gets to 5 000, count shapes instead of statements, since statements sharing a shape share an entry. The walk below goes over 400 tables with sixteen read shapes each, two of which turn out to collide on most tables, something the entry counts imply without naming. Before the first flood the view held a hot application query at 500 calls. Then 5 120 statements went through, 320 tables at sixteen shapes. ``` entries_after | dealloc_after ---------------+--------------- 4805 | 0 (1 row) ``` Nothing was evicted at that size, and of the 4 805 entries the walk itself accounts for 4 801, fifteen per table bar one that managed sixteen, with the remaining four already in the view when it started. A second run, this time planting a nightly report alongside the hot query before walking all 400 tables, 6 400 statements, crosses the line. ``` entries | dealloc ---------+--------- 4755 | 5 (1 row) calls | survived -------+------------------------------------------------------ 500 | SELECT count(*) FROM orders WHERE customer_id = $1 1 | SELECT status, sum(amount) FROM orders GROUP BY stat (2 rows) ``` Eviction ran five times during that walk and took neither of the application's entries, the report that had run exactly once included. Going in, the expectation on this stand was that agent traffic would push the application's statistics out of the view, and the run contradicted it, since eviction discards the least-executed entries and a statement at 500 calls is nowhere near that end of the list. The flood took the agent's own record of its writes instead. Running the schema walk twice, 12 800 read statements, took `dealloc` to 20, and the forty `DELETE` statements the run had opened with, one per table and each of them recorded at the time, were no longer there. ``` oneoff_writes_recorded ------------------------ 40 (1 row) entries | dealloc ---------+--------- 4895 | 20 (1 row) oneoff_writes_surviving ------------------------- 0 (1 row) ``` Naming the mechanism precisely matters here, because it is not the literals. Each of those forty statements read `DELETE FROM t### WHERE id = 1`, the same value in each, so the jumble would have merged them on that basis alone. Forty different table names kept them apart instead, since PostgreSQL 18's relation-name grouping only reaches same-named tables sitting in different schemas. In a control run, forty deletes against one table, differing only in their literals, normalized into a single entry carrying 40 calls, and that entry survived the identical 12 800-statement flood. ## What still answers the question Turning on `log_min_duration_statement`, with a `log_line_prefix` that carries `%u` and `%a`, produces the line the view cannot hold, since the role, the database, the client's declared name and the literal value all land in one place. ``` 2026-09-22 10:24:43.980 UTC [355] agent@postgres app=claude-agent LOG: duration: 1.209 ms statement: SELECT count(*) FROM orders WHERE customer_id = 4242; 2026-09-22 10:24:44.242 UTC [362] app@postgres app=payments-api LOG: duration: 1.062 ms statement: SELECT count(*) FROM orders WHERE customer_id = 4242; ``` That separation works because the log writes a line per statement where the view keeps a row per shape, so `%u` and `%a` each split these two callers on their own. The durations are also a warning against the obvious economy, since the agent's statement and the application's differ by 0.147 ms, and a threshold placed inside a gap that narrow sorts callers by noise. Choosing one means measuring your own latencies rather than assuming the agent is the slow caller. Give the agent its own role regardless, and do it before the incident that makes you want it. A role separates its rows for as long as they are resident, gives the log a field to filter on that outlives them, shows up in `pg_stat_activity`, and carries a `CONNECTION LIMIT` you can turn down. Seedfast creates a role for its own connections, so its statements stay attributable in the log once `%u` is in the prefix. A role also holds where `application_name` does not, since the role is recorded at authentication while [`application_name` is a value the session picks for itself](/blog/postgres-role-for-ai-agent). PostgreSQL 19 adds `generic_plan_calls` and `custom_plan_calls`, described in the release notes as generic and custom plan counts, which answers a question the view could not answer before. Neither column addresses the two gaps above, and the `queryid` renumbering across majors means the upgrade starts the record over, so plan against the version you are actually running. ### `pg_stat_statements must be loaded via "shared_preload_libraries"` One last trap sits at the front of all of this, and it is the kind an agent asked to "enable query statistics" walks straight into. Creating the extension on a server started without the library preloaded works. ``` CREATE EXTENSION pg_stat_statements; CREATE EXTENSION SELECT count(*) FROM pg_stat_statements; ERROR: pg_stat_statements must be loaded via "shared_preload_libraries" ``` The `CREATE EXTENSION` succeeds and the catalog entry is real, while reads of the view keep failing until `shared_preload_libraries` names the library and the server has been restarted. An agent reporting that first command's success as the job being done is describing the command it ran, which is a narrower claim than the one you asked it for. ## Frequently asked questions ### Why are some of my queries missing from pg_stat_statements? Most of the missing ones went one of a few ways. A statement that raised an error was never recorded at all, since recording happens when execution finishes. If it ran nested inside a function, procedure or `DO` block while `pg_stat_statements.track` sat at its default `top`, the view kept only what the client sent. And where the entry did exist and was discarded to make room, `pg_stat_statements_info.dealloc` counts the discards without naming a single casualty. ### How many distinct statements does it take before eviction starts? The threshold is `pg_stat_statements.max`, which is 5 000 unless someone changed it, and the figure to compare against is shapes, not raw statement counts. On this stand, 5 120 statements over 320 tables came to 4 805 entries with no eviction, while 6 400 statements over 400 tables crossed the line and took `dealloc` to 5. To size it for your own server, multiply the shapes your agent produces per table by the tables it can reach, then compare that product with the ceiling. ### Can I see which application_name ran a statement? The view has no column for it, since its grouping key stops at `userid`, `dbid`, `toplevel` and `queryid`. `pg_stat_activity` carries `application_name` for sessions currently connected, and the log carries it for everything once `log_line_prefix` includes `%a`. For a durable split inside the view itself, the caller needs a role of its own. ### Does raising pg_stat_statements.max require a restart? Raising it takes a restart, because the parameter carries `postmaster` context and the entries live in a shared memory area sized at startup, which a reload cannot resize. Budget that memory before raising the value, and note that switching `pg_stat_statements.track` to `all` adds a row for every distinct nested shape your workload runs, all of them competing for the same ceiling. ## Related guides - [Your agent can turn off its own kill switch](/blog/postgres-role-for-ai-agent) covers USERSET parameters and why a value pinned onto a role is a starting point rather than a limit, `application_name` among them. - [Your agent is reading someone else's tenant](/blog/pgbouncer-transaction-mode) follows the same tenant key through a transaction-mode pooler, where `SET` outlives the caller that issued it. - [What replica mode does not switch off](/blog/session-replication-role) is the matrix for `session_replication_role`, the superuser-context parameter a role genuinely can be stopped from setting. --- # Your Agent Is Reading Someone Else's Tenant > Source: https://seedfa.st/blog/pgbouncer-transaction-mode > Last updated: 2026-09-18 Two client connections, opened one after the other through PgBouncer transaction mode, asked the same database whose rows they were allowed to see, and the second one got the first one's answer. It had set nothing, it had never met the first caller, and the rows it read belonged to that caller's tenant. Any runbook that keeps a tenant key in a session variable sits one pooler away from this, and since 28 July the MCP protocol carries no session of its own, so a tool call from an agent arrives in this shape by default. The setup is the one most multi-tenant guides teach. A row level security policy reads `current_setting('app.tenant', true)`, each request or tool call opens with `SET app.tenant`, and on a connection nobody else is using the rows that come back belong to whoever asked for them. Through the pooler, the first call still behaves as written. ``` SET app.tenant = 'a'; SET SELECT current_setting('app.tenant') AS tenant; tenant -------- a (1 row) SELECT tenant, body FROM docs; tenant | body --------+---------------- a | alpha invoice a | alpha contract (2 rows) ``` A second client connection, opened after the first one had closed and setting nothing of its own, then asks the database who it is working for and what it may read. ``` SELECT current_setting('app.tenant', true) AS inherited_tenant; inherited_tenant ------------------ a <-- set by the caller before it (1 row) SELECT tenant, body FROM docs; tenant | body --------+---------------- a | alpha invoice a | alpha contract (2 rows) ``` Both callers ran on one backend, as did the two after them that switched the tenant to `b` and inherited it. When caller A's implicit transaction ended, PgBouncer released that backend and handed it over without sending anything in between that would have cleared `app.tenant`. Two changes made that shape the ordinary one. On 28 July 2026 the MCP specification took the session out of the protocol, its [announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28/) stating that "Each request now travels on its own, carrying its protocol version, client identity, and client capabilities in `_meta`", and hosted Postgres now hands out a pooled endpoint beside the direct one. Writing on 17 September, Christophe Pettus reached the neighboring problem in [The Call Is Coming From Inside the Session](https://thebuild.com/blog/the-call-is-coming-from-inside-the-session/), where a session undoes the limits placed on it. The failure below starts a step earlier, with a session that was never this caller's. ## Key Takeaways - The next client connection read the previous caller's `app.tenant`, its `SET ROLE`, its `search_path` and its `statement_timeout`, and it could also reach the temp tables, SQL `PREPARE` statements, `WITH HOLD` cursors, `LISTEN` subscriptions and session-level advisory locks left on that backend. - Whatever belongs to a transaction dies with it, so `SET LOCAL`, `set_config(..., true)` and `pg_advisory_xact_lock` were gone before the next caller arrived. - Handing a backend over, PgBouncer restores `client_encoding`, `datestyle`, `timezone`, `standard_conforming_strings`, `application_name` and whatever `track_extra_parameters` names, which is why a leaked `timezone` is not on that list. - Once any caller has touched a custom parameter, `current_setting('app.tenant', true)` returns the empty string in place of NULL for the life of that backend, which breaks an `IS NULL` test and a `COALESCE` default. - With `default_pool_size` at 1, one client idling inside a transaction took the pool away from everybody, and the caller behind it was disconnected with `FATAL: query_wait_timeout`. - Wrap each request in one explicit transaction, set the tenant inside it with `SET LOCAL`, and leave nothing on the backend that the `COMMIT` does not remove, which rules out `LISTEN` and SQL-level `PREPARE` on a pooled connection altogether. Everything below was measured on PostgreSQL 18.6 and PgBouncer 1.25.2 in Docker on 18 September 2026, against one pooled backend except where a transcript names a second one, with every experiment starting from an explicit `DISCARD ALL` reset. ## PgBouncer transaction mode and what the next client inherits Start with the process id, because it settles one question before the others get interesting. A single statement, run by three client connections that opened and closed one after another through the pooler, came back the same every time. ``` SELECT pg_backend_pid(); pg_backend_pid ---------------- 137 (1 row) ``` Backend 137 answered all three of them, while two clients that skipped the pooler and dialed the server got 184 and 191, a new process each, the picture everyone carries when reasoning about what a session owns. Which pieces of that session survive a handover is the open question, and the answer depends on the kind of state involved. ![Three client connections served by one pooled backend in PgBouncer transaction mode, with the app.tenant value set by the first caller still in place when the second caller reads its rows](/blog/pgbouncer-transaction-mode-2.svg) | State the caller leaves behind | Lives in | After the caller's transaction ends | Next client on that backend | Transcript | |---|---|---|---|---| | `SET app.tenant`, `set_config(..., false)` | session | still set | LEAKS, reads `a` | the tenant key | | `SET LOCAL app.tenant`, `set_config(..., true)` | transaction | discarded | LOST, reads the empty string | the tenant key | | `SET ROLE tenant_a` | session | still set | LEAKS, `current_user` is `tenant_a` | current_user | | `SET LOCAL ROLE tenant_b` | transaction | discarded | LOST, back to `agent` | current_user | | `SET search_path = sandbox, public` | session | still set | LEAKS, one query text reaches another table | current_user | | `SET statement_timeout` | session | still set | LEAKS, upward and downward | the cap | | `options=-c statement_timeout=0` at login | startup packet | never applied | REFUSED at login, or dropped | the cap | | `CREATE TEMP TABLE` | session temp schema | still there | LEAKS, with the rows in it | FETCH from a stranger | | `PREPARE` (SQL level) | session | still there | LEAKS, `EXECUTE` runs it | FETCH from a stranger | | Parse/Bind, `max_prepared_statements = 200` | pooler, per client | re-parsed per client | REFUSED, client disconnected | FETCH from a stranger | | Parse/Bind, `max_prepared_statements = 0` | session | still there | LEAKS, silent wrong result | FETCH from a stranger | | `DECLARE ... CURSOR WITH HOLD` | session | outlives COMMIT by design | LEAKS, with its rows | FETCH from a stranger | | `LISTEN events` | backend | still subscribed | LEAKS, notifications follow the backend | FETCH from a stranger | | `pg_advisory_lock(42)` | session | still held | LEAKS, and stacks | advisory locks | | `pg_advisory_xact_lock(43)` | transaction | released | LOST | advisory locks | | `application_name`, `timezone`, `IntervalStyle` | session, tracked per client | restored for the new client | PUT BACK, at their defaults | below | Read the table by its second column, since anything belonging to a transaction is gone when the transaction ends and that is the only cleanup a transaction-mode pool performs unasked. Everything else stays where the caller left it, because the reset query that would remove it is not sent in this mode, which PgBouncer's config page states under `server_reset_query_always`, reading "When this setting is off (default), the server_reset_query will be run only in pools that are in sessions-pooling mode". Against all of that, one row in the table is the pooler working for the new client. PgBouncer keeps a short list of parameters per client connection and restores them at handover, the default list being "client_encoding, datestyle, timezone, standard_conforming_strings and application_name" plus whatever `track_extra_parameters` adds. Caller A set three of those to values nobody picks twice, and the next client found the defaults. ``` SHOW application_name; application_name ------------------ psql (1 row) SHOW timezone; TimeZone ---------- Etc/UTC (1 row) SHOW intervalstyle; IntervalStyle --------------- postgres (1 row) ``` Reproducing the leak on a laptop is harder than it looks, and `server_round_robin` is why. PgBouncer reuses server connections last-in-first-out by default, so a lone client against an idle pool keeps landing on the backend it just released and reads its own state back. Give the pool two backends and one competitor for them, and a single client connection moves between backends between two of its own statements. ``` SET app.tenant = 'a'; SET SELECT pg_backend_pid() AS pid_now, current_setting('app.tenant', true) AS tenant_now; pid_now | tenant_now ---------+------------ 676 | a (1 row) \! sleep 8 SELECT pg_backend_pid() AS pid_now, current_setting('app.tenant', true) AS tenant_now; pid_now | tenant_now ---------+------------ 667 | <-- same client connection, other backend (1 row) SELECT tenant, body FROM docs; tenant | body --------+------ (0 rows) ``` Caller A never disconnected during any of that, its `SET` having gone to backend 676, while a holder released 667 during the sleep and the pooler handed 667 out next as the most recently freed connection. On a connection that had set a tenant eight seconds earlier, zero rows is the policy working correctly against an empty one. [A question posted to Stack Overflow on 15 September](https://stackoverflow.com/q/80003232) reports the same mechanism on an application-side pool, with a small share of concurrent requests returning another tenant's rows and the asker unable to reproduce the fault using `SET LOCAL` inside a transaction. ## The tenant key, and the empty string that is not NULL Doing the same work inside a transaction is what fixes this, and the fix is worth watching before the failures pile up. Caller A wrapped one call the way a per-call shape wants it wrapped. ``` BEGIN; BEGIN SET LOCAL app.tenant = 'a'; SET SELECT tenant, body FROM docs; tenant | body --------+---------------- a | alpha invoice a | alpha contract (2 rows) COMMIT; COMMIT ``` The manual grants no exceptions, saying of [`SET LOCAL`](https://www.postgresql.org/docs/18/sql-set.html) that "The effects of SET LOCAL last only till the end of the current transaction, whether committed or not". The next client through the pooler confirms it and shows the part that catches people out. ``` SELECT current_setting('app.tenant', true) IS NULL AS tenant_is_unset; tenant_is_unset ----------------- f (1 row) SELECT count(*) AS visible_rows FROM docs; visible_rows -------------- 0 (1 row) ``` Zero visible rows is the right answer, so look instead at the `f` above it, because `current_setting('app.tenant', true)` returned something that is neither NULL nor `a`. It returned the empty string and will go on returning it for the life of that backend, because a two-part name that has been touched once stays defined as a placeholder, the kind PostgreSQL says has "no function until the module that defines them is loaded". A second entrance to the same trap runs through `set_config`. Pass `true` for `is_local` with no `BEGIN` anywhere, and the function returns the value it was handed while the statement after it reads nothing. ``` SELECT set_config('app.tenant', 'a', true); set_config ------------ a (1 row) SELECT current_setting('app.tenant', true) AS same_call_next_statement; same_call_next_statement -------------------------- (1 row) ``` Each statement in autocommit is its own transaction, so `is_local = true` covers precisely the statement that ran it. Correct as that is, it makes the least useful possible reading of `SET LOCAL` in Postgres, because a tool call sending its `set_config` and its query as two round trips has lost the setting by the time the query arrives. ### `SET LOCAL can only be used in transaction blocks` On a direct connection with one session and nobody else in it, the empty string arrives at the first `RESET ALL` and never leaves. ``` -- fresh backend, parameter never set in this session SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty; is_null | is_empty ---------+---------- t | (1 row) SET app.tenant = 'a'; SET RESET ALL; RESET SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty; is_null | is_empty ---------+---------- f | t (1 row) DISCARD ALL; DISCARD ALL SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty; is_null | is_empty ---------+---------- f | t (1 row) ``` `DISCARD ALL` does not bring NULL back, which matters because `DISCARD ALL` is what a pooler would run if it ran anything. Since the value is never NULL again, code testing `current_setting('app.tenant', true) IS NULL` to decide whether a request identified itself is right exactly once per backend, for the first caller that backend ever serves. Wrapping the same call in `COALESCE(..., 'default')` fares worse, because the empty string is a value and goes straight through to whatever relied on the fallback. The policy survives all of it, since `tenant = ''` matches no row. Issuing `SET LOCAL` outside a transaction block is the other road to an empty value, and the server does warn. ``` SET LOCAL app.tenant = 'c'; SET SELECT current_setting('app.tenant', true) AS after_set_local_outside_txn; WARNING: SET LOCAL can only be used in transaction blocks after_set_local_outside_txn ----------------------------- (1 row) ``` The server answered `SET` and left the value empty, having sent the warning wherever the driver sends warnings. A client library that swallows notices makes the exchange look successful, and the statement behind it reads a tenant nobody set. PgBouncer can track additional parameters, so the obvious repair is to name `app.tenant` in `track_extra_parameters`. Trying that here took a restart, because the setting is not reloadable and `SHOW CONFIG` reports it as `changeable = no`; with `IntervalStyle, app.tenant` in place, caller A set the tenant to `a` on backend 822, and the next client connection landed on 822 and read this. ``` SELECT pg_backend_pid(), current_setting('app.tenant', true) AS inherited_tenant; pg_backend_pid | inherited_tenant ----------------+------------------ 822 | a (1 row) ``` The control in the same run behaved, with `IntervalStyle` set by one caller and reported as `postgres` by the next, so the tracking works and simply cannot see this parameter. One line of the documentation says why, that "The only parameters that can be tracked are ones that Postgres reports to the client", and an `app.*` placeholder never appears in the parameter status messages the server sends back. ## `current_user` says tenant_a, and nobody asked People push roles into the session for the same purpose, with `SET ROLE tenant_a` before the query and `RESET ROLE` after it. After the first half of that, caller A read `tenant_a | agent`. A new client connection then asked the same question without going near a role. ``` SELECT current_user, session_user; current_user | session_user --------------+-------------- tenant_a | agent (1 row) ``` `session_user` is still `agent`, since that is who authenticated, while `current_user` is somebody else's tenant, and every privilege check and every policy evaluated for this caller now runs against that identity. Caller C, working inside `BEGIN; SET LOCAL ROLE tenant_b; ... COMMIT;`, saw `tenant_b | agent` while its transaction was open, and caller D on the same backend afterwards was itself again. ``` SELECT current_user, session_user; current_user | session_user --------------+-------------- agent | agent (1 row) ``` Harder to notice is `search_path`, because nothing in the next caller's SQL looks wrong. Caller A pointed it at a schema of its own, `sandbox`, which holds a table also named `docs` with no policy on it, and the following client connection ran the query it always runs. ``` SHOW search_path; search_path ----------------- sandbox, public (1 row) SELECT * FROM docs; tenant | body --------+------------------------------------------------- x | this row lives in sandbox.docs, not public.docs (1 row) ``` The query text did not change while the table did, and the policy on `public.docs` never came into it because `public.docs` was never read. Any later query resolving an unqualified name on a caller's behalf inherits whichever `search_path` the last caller left behind, and that reaches further than the tool call in front of you, down into the plans an ORM built months ago for a query nobody has reread since. ## The cap the previous caller removed A role carrying `statement_timeout = '2s'` is the standard piece of containment, and [an earlier article here](/blog/postgres-role-for-ai-agent) works through what a session can do to its own limits with USERSET parameters and the startup packet. Put a pooler underneath and the same act acquires a second victim. ``` SHOW statement_timeout; statement_timeout ------------------- 2s (1 row) SET statement_timeout = 0; SET ``` Caller B, a new client connection that issued no `SET` at all, inherited the missing cap, and a four-second query ran to completion. ``` SHOW statement_timeout; statement_timeout ------------------- 0 (1 row) SELECT setting, source FROM pg_settings WHERE name = 'statement_timeout'; setting | source ---------+--------- 0 | session <-- nothing on this connection ran SET (1 row) \timing on Timing is on. SELECT pg_sleep(4); pg_sleep ---------- (1 row) Time: 4007.830 ms (00:04.008) \timing off Timing is off. ``` That `source` column reads `session`, which is `pg_settings` recording that some client set the value on this backend without recording which one, so a watchdog reading the column sees a session that lifted its own timeout while looking at a caller that did nothing. The leak runs downhill as readily, with caller C lowering the cap to 100 milliseconds and caller D cancelled on an ordinary half-second query. ``` SHOW statement_timeout; statement_timeout ------------------- 100ms (1 row) SELECT pg_sleep(0.5); ERROR: canceling statement due to statement timeout ``` ### `unsupported startup parameter in options` The quieter route into the same parameter is the libpq `options` field in the startup packet, and that is the one a pooler stops. A connection carrying `options='-c statement_timeout=0'` never reaches a login. ``` psql: error: connection to server at "bouncer" (172.26.0.3), port 5432 failed: FATAL: unsupported startup parameter in options: statement_timeout ``` PgBouncer logged the same text as a warning and closed the client before it had a pool. Sent straight to the server the identical string was accepted and `SHOW statement_timeout` came back `0`, so the refusal belongs to the pooler. Making the pooler permissive buys the caller nothing either, since a variant with `ignore_startup_parameters` widened to cover `options` let the login through and dropped the switches. ``` SHOW statement_timeout statement_timeout ------------------- 2s (1 row) SELECT setting, source FROM pg_settings WHERE name='statement_timeout' setting | source ---------+-------- 2000 | user (1 row) ``` Two seconds with `source = user` is the role's own default, untouched. Between the refusal and the shrug, in the two configurations tested here, there is no way through a PgBouncer for a startup-packet parameter it does not understand, so the pooler turns out to be the one component in this setup that takes the operator's side, and a connection string that would have lifted the cap on a direct connection arrives at the role's own two seconds instead. ## Things you can FETCH from a stranger Parameters are the polite half of the problem, because the rest of a session is objects with contents, and those change hands with their data intact, starting with the temp table, a real table in a schema private to the backend that nothing in the handover removes. ### `relation "scratch" already exists` Caller A created `scratch` and wrote one row into it. The next client connection read that row, then tried to create its own scratch table under the name every scratch table has. ``` SELECT * FROM scratch; who --------------------- written by caller A (1 row) CREATE TEMP TABLE scratch (who text); ERROR: relation "scratch" already exists ``` The read handed a stranger's data to a caller that had asked for its own, and then the create failed against a temp schema this caller did not know it was sharing, two separate faults out of one five-line transcript. Declaring the table `ON COMMIT DROP` is the form that survives a transaction pool, because then the drop belongs to the transaction. Worse in one respect is a holdable cursor, which exists precisely to outlive its transaction. Caller A declared one over a three-row table inside `BEGIN ... COMMIT`, and caller B fetched all of it out of a cursor it had never declared, from a table it had never named. ``` FETCH ALL FROM c; body ------------ note one note two note three (3 rows) ``` The subscription `LISTEN` creates belongs to the backend process and not to the client that issued it, so a caller that never issued it can find itself subscribed to somebody else's channel, and whichever client holds that backend when a `NOTIFY` fires is the one that receives the payload. ``` SELECT * FROM pg_listening_channels(); pg_listening_channels ----------------------- events (1 row) ``` ### `prepared statement "s1" already exists` Prepared statements split into two mechanisms with different outcomes, and one branch produces the only silent wrong answer in this article. SQL-level `PREPARE` creates a session object like any other, so after caller A prepared `q`, caller B executed a statement it had never prepared and then collided with the name. ``` EXECUTE q(41); ?column? ---------- 42 (1 row) PREPARE q AS SELECT 1; ERROR: prepared statement "q" already exists ``` Protocol-level Parse and Bind, which is what a driver sends for a named statement, is the case PgBouncer has handled since 1.21.0, the release whose [changelog](https://www.pgbouncer.org/changelog.html) announced "support for protocol-level named prepared statements", and which 1.24.0 turned on by default with `max_prepared_statements` at 200. At that default the pooler keeps its own map of client statement names and re-parses them on whichever backend the client lands on, so when caller B bound a name it had never parsed, the pooler dropped the connection. ``` \bind_named s1 21 \g FATAL: prepared statement did not exist server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost ``` Rudeness aside, the disconnect is the right answer, and caller C parsed its own `s1` afterwards and got `tripled` back, so the tracking did the job it was added for. Turn it off with `max_prepared_statements = 0`, and caller B bound a statement it never parsed and was answered with 42, while caller C was told the name was taken and bound its parameter regardless. ``` SELECT $1::int * 3 AS tripled \parse s1 \bind_named s1 7 \g ERROR: prepared statement "s1" already exists doubled --------- 14 <-- caller A's statement, caller C's parameter (1 row) ``` The column header gives it away, since caller C asked for `tripled` and seven times three is twenty-one, while the number that came back is fourteen, seven times two, computed by caller A's statement against caller C's parameter. Nothing raised an error the application could catch, and a value wrong by half again went back to whatever had asked the question. ## `you don't own a lock of type ExclusiveLock` Where one job has to run at a time, the usual instrument is a session-level advisory lock, and here it fails in the direction that costs money. Caller A took lock 42 and disconnected, after which caller B asked for the same lock and waited for nothing. ``` SELECT pg_try_advisory_lock(42) AS got_it; got_it -------- t (1 row) SELECT pid, objid, granted FROM pg_locks WHERE locktype = 'advisory'; pid | objid | granted -----+-------+--------- 137 | 42 | t (1 row) ``` It got the lock because the backend was already holding it, and the manual explains the arithmetic, noting that "Multiple session-level lock requests stack, so that if the same resource identifier is locked three times there must then be three unlock requests to release the resource in advance of session end". Two callers now believe they hold a mutex, and a third can settle the argument for both of them. ``` SELECT pg_advisory_unlock(42) AS first, pg_advisory_unlock(42) AS second, pg_advisory_unlock(42) AS third; first | second | third -------+--------+------- t | t | f (1 row) WARNING: you don't own a lock of type ExclusiveLock ``` Two unlocks succeeded against a lock this caller never took, and when the third found nothing left, the warning became the only trace of any of it. Take the transaction-level function instead and none of that happens, as caller D found with `pg_advisory_xact_lock(43)` inside a transaction, after which caller E counted zero advisory locks. Any scheduler holding a session lock across a transaction pool is holding a mutex the pool can hand to the next stranger on the same backend for free. ## One idle transaction, and everyone waits The availability failure needs no leak at all, and at a `default_pool_size` of 1 the pool here is small enough to watch the mechanism in one screen, which is also the shape any deployment reaches on the day its concurrency outruns its pool, whatever the configured size happens to be. A holder opened a transaction and sat there after a single statement. ``` SELECT pid, usename, state, now() - xact_start AS in_txn_for, left(query, 30) AS query FROM pg_stat_activity WHERE usename = 'agent'; pid | usename | state | in_txn_for | query -----+---------+---------------------+-----------------+-------------------------------- 144 | agent | idle | | select 'via bouncer0 ok', pg_b 485 | agent | idle in transaction | 00:00:02.341321 | SELECT 1; (2 rows) ``` ### `query_wait_timeout` An `idle in transaction` backend is not released, so everybody else queues behind it, and the queue has a deadline. ``` SELECT 1 AS reached_the_server; FATAL: query_wait_timeout server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. connection to server was lost (caller B waited 5 s) ``` `query_wait_timeout` was lowered to 5 seconds here so the demonstration would finish, while the PgBouncer default is 120 seconds, two minutes of a client holding a query it has already sent, with `SHOW POOLS` reporting `cl_active 1`, `sv_active 1` and `sv_idle 0` for the pool for as long as the wait lasted. The failure people brace for next did not happen. A client killed mid-transaction, with `kill -9` landing on its process while an uncommitted temp table sat on the backend, handed its half-finished work to nobody. ``` 2026-09-18 09:36:19.963 UTC [1] LOG C-0x7f1aca25c3b0: app/agent@172.26.0.8:49456 closing because: client unexpected eof (age=2s) 2026-09-18 09:36:19.963 UTC [1] LOG S-0x7f1aca2063d0: app/agent@172.26.0.2:5432 closing because: client disconnect while server was not ready (age=188s) ``` Seeing the client's end of file, PgBouncer found its server connection in a state that was not ready and threw the backend away without trying to tidy it. `SHOW SERVERS` returned no rows, and the caller behind all of this was given a new process. ``` SELECT pg_backend_pid(); pg_backend_pid ---------------- 773 (1 row) SELECT count(*) AS temp_tables_named_t_in_txn FROM pg_class WHERE relname = 't_in_txn'; temp_tables_named_t_in_txn ---------------------------- 0 (1 row) ``` Backend 773 has never heard of `t_in_txn`, and in the one case that involves a half-finished transaction, where inconsistent state would be worse than leaked state, the pooler closes the connection instead of recycling it. ## Making a tool call a transaction ![The measured matrix for PgBouncer transaction mode, listing which session state leaks to the next client on the same backend, which is lost at COMMIT, and which the pooler restores per client](/blog/pgbouncer-transaction-mode-3.svg) The repair for all of it appeared near the top of this article, in the block where caller A wrapped one call. Open an explicit transaction at the start of the call, set the tenant inside it with `SET LOCAL` or with `set_config` and `is_local` true, add `SET LOCAL ROLE` where a policy tests an identity, run the work, commit, and let the call own nothing that the commit does not take away. Follow it and most of the table settles itself, with advisory locks becoming the transaction-level functions, cursors declared `WITHOUT HOLD` or consumed inside the transaction that declared them, temp tables carrying `ON COMMIT DROP`, `LISTEN` kept off a pooled connection entirely, and prepared statements coming from the driver's protocol support with `max_prepared_statements` above zero instead of a SQL `PREPARE` that nothing will deallocate. Finding the calls that still run outside a transaction is the tedious part, and the server log is the honest tool for it, since `log_statement = 'all'`, set on the agent role from a superuser session, writes every statement with its backend pid, and a `SET` with no `BEGIN` ahead of it on the same pid is the call to fix. Hosted poolers such as Supabase's Supavisor are separate implementations that this stand did not measure, so run the `pg_backend_pid()` probe and the two-connection `SET` test against them before trusting either answer. Blunter instruments exist and each carries a price worth knowing first. Switching `server_reset_query_always` on sends `DISCARD ALL` after every transaction, and caller B on such a pool found an empty tenant and no advisory locks, with both of caller A's objects gone as well. ``` SELECT current_setting('app.tenant', true) AS inherited_tenant; inherited_tenant ------------------ (1 row) SELECT count(*) AS advisory_locks_held FROM pg_locks WHERE locktype = 'advisory'; advisory_locks_held --------------------- 0 (1 row) EXECUTE q(41); ERROR: prepared statement "q" does not exist SELECT * FROM scratch; ERROR: relation "scratch" does not exist LINE 1: SELECT * FROM scratch; ^ ``` A client that prepared its statements once and built a temp table for a multi-step job loses both at every commit with no way to ask for an exception, and the last two errors above are the bill, which is why PgBouncer frames the option as a way of "working around broken setups that run applications that use session features over a transaction-pooled PgBouncer", one that "changes non-deterministic breakage to deterministic breakage". Session pooling keeps the session whole, at the cost of the multiplexing that was the point of running a pooler in the first place. A direct connection per call reaches the same safety by a slower road, since every call now pays for its own connection setup. Whatever the client does, two pieces belong on the server side. Write the policy so an empty string fails closed, which `tenant = current_setting('app.tenant', true)` already manages, and keep `COALESCE` and `IS NULL` out of the path that decides whether a request identified itself. Give every identity its own login role as well, so that a watchdog filtering `pg_stat_activity` on `usename` has something to match that no session can rewrite, the control [Pettus argues for](https://thebuild.com/blog/the-call-is-coming-from-inside-the-session/) from the other direction. Workloads that never share a backend never meet this matrix, and a batch loader gets there by opening a direct connection for the length of its run. Seedfast is built for that shape, loading a generated dataset over connections it opens itself and closes on exit, which is why the seeding guides for Neon, Supabase and Prisma Postgres all point at the direct string and away from the pooled one, whereas an MCP server multiplexing many agents through one pooled endpoint is the shape this matrix describes. On PgBouncer's own feature page, transaction pooling "breaks client expectations of the server by design" and "can be used only if the application cooperates by not using non-working features", so none of what was measured here is a defect in the pooler, and that has been the deal from the start. The party doing the cooperating has changed, because a tool call assembled by a model arrives with no memory of the one before it, and nobody in that loop was ever assigned to read the connection's history and decide what to clean up. ## Frequently asked questions ### Why does SET leak between requests on a pooled connection? The pooler gives the connection back to the pool when your transaction ends, and nothing runs on it in between. A plain `SET` writes a session-level value, the session belongs to the backend process and not to your client, and `server_reset_query` is sent only in session-pooling mode unless `server_reset_query_always` is turned on. Measured here, a caller that issued no such statement of its own read the previous caller's `app.tenant` off the backend they shared. The Stack Overflow question above describes the same symptom on an application-side pool, where a connection some earlier request configured is handed to the next one. ### Is SET LOCAL enough for row level security with PgBouncer? Yes, provided the `SET LOCAL` and the query it protects sit inside one explicit transaction. Its effect ends at `COMMIT` or `ROLLBACK`, so the value cannot reach the next client on that backend, and the caller that followed in this test saw zero visible rows. Two things spoil it, since running `SET LOCAL` outside a transaction block leaves you a warning and an empty value, and `set_config(..., true)` in autocommit covers only the statement that called it, so a query sent on the next round trip reads nothing. ### Does PgBouncer support prepared statements in transaction mode? Protocol-level named statements, yes, since 1.21.0 by the changelog and on by default since 1.24.0, through `max_prepared_statements`, which defaults to 200 and makes the pooler responsible for re-issuing a client's Parse on whichever backend serves its next transaction. Bind a name the pooler has no parse for and it drops your connection instead of answering. SQL-level `PREPARE` is a different thing entirely and is not tracked, so the statement stays on the backend where the next caller can `EXECUTE` it or collide with its name. Set `max_prepared_statements` to zero and you get the worst result measured on this stand, a `Bind` from one caller running another caller's statement and returning a plausible wrong number. ### What does server_reset_query_always do, and why do the docs discourage it? With it on, PgBouncer runs `server_reset_query` in transaction mode too, so `DISCARD ALL` lands after every transaction and the backend returns to the pool carrying nothing. On this stand that removed the leaked tenant, the advisory lock, the prepared statement and the temp table in one step. The documentation calls it a way of working around broken setups because it cannot tell state a caller leaked from state a caller still needs, so a client doing legitimate multi-statement work loses its objects at every commit. It earns its place in the config only for as long as the application code still needs fixing. ## Related guides - [Your agent can turn off its own kill switch](/blog/postgres-role-for-ai-agent) explains USERSET parameters, the startup-packet route that this pooler refuses, and why a watchdog has to live in another session. - [Forty deletes vanished from pg_stat_statements](/blog/pg-stat-statements-missing-queries) follows the same tenant key into the statistics, where PostgreSQL 18 replaced the value of `SET app.tenant` with a placeholder. - [Seeding a Supabase database](/blog/supabase-db-seed) sets the session pooler on port 5432 against the transaction pooler on 6543 and says which of them a seed survives. - [Seeding a Neon database](/blog/seed-neon-database) shows why the pooled hostname breaks a long script that the unpooled string carries through. - [Prisma Postgres seeds](/blog/prisma-postgres-seed) covers the pooled endpoint, prepared statements, and the error a seed hits halfway. --- # Your Agent Can Turn Off Its Own Kill Switch > Source: https://seedfa.st/blog/postgres-role-for-ai-agent > Last updated: 2026-09-11 A new agent needs a database login, so the role it logs in as gets the treatment every runbook recommends. Two `ALTER ROLE` lines go in, one capping how long a single statement may run and one making transactions read-only by default. From the operator's side, the Postgres role for AI agent sessions now looks contained. ``` alter role agent set statement_timeout = '2s'; ALTER ROLE alter role agent set default_transaction_read_only = on; ALTER ROLE select rolname, rolconfig from pg_roles where rolname='agent'; rolname | rolconfig ---------+--------------------------------------------------------- agent | {default_transaction_read_only=on,statement_timeout=2s} (1 row) ``` Both settings bite at the baseline, with `SELECT pg_sleep(4)` cancelled at 2.060 seconds on the role above and a bare `CREATE TABLE` refused on a second role carrying only the read-only default. Then the agent's own session answers, in one statement. ``` SET default_transaction_read_only = off; SET SHOW default_transaction_read_only; default_transaction_read_only ------------------------------- off (1 row) SELECT source FROM pg_settings WHERE name = 'default_transaction_read_only'; source --------- session (1 row) CREATE TABLE app.via_set (i int); CREATE TABLE INSERT INTO app.via_set VALUES (1); INSERT 0 1 ``` ![The pg_settings source column for a Postgres role for an AI agent, showing the value ALTER ROLE SET writes as source user and the two ways a session replaces it, with SET arriving as source session and a connection string arriving as source client](/blog/postgres-role-for-ai-agent-2.svg) One column moved, from `user` to `session`, and that column is the whole story. What `ALTER ROLE ... SET` writes is the value a session starts with; whether the session stays there is up to the session, because every parameter in that opening pair carries a context of `user`. The quieter route skips `SET` entirely and needs no SQL at all. ## Key Takeaways - `statement_timeout`, `transaction_timeout`, `lock_timeout`, the two idle timeouts, `default_transaction_read_only` and `application_name` all have `context = user`, so the session the value was meant to restrain is the session allowed to change it. - A connection string carrying `options=-c statement_timeout=0` arrives with `source = client`, which outranks the role's `user`, so the cap is gone before the first query runs and nothing resembling a `SET` ever reaches the log. - The `SET` privilege on a parameter is only meaningful where superuser privilege would normally be required. Revoking `SET` on `statement_timeout` writes no row to `pg_parameter_acl` and stops nothing. - What held, in the same containers, was `CONNECTION LIMIT`, the object grants, and a second privileged session calling `pg_terminate_backend`. None of the three is reachable from inside the session it binds. The transcripts below came out of throwaway Alpine containers on 2026-09-11. Unless a block says otherwise it was captured on PostgreSQL 18.6; the transaction-timeout comparison ran on 17.11 and 16.15. Server output is quoted verbatim; a few blocks are condensed, with every message and value left as it came back. ## The quieter route, through the connection string A `SET` at least leaves something to find. Search the logs for `SET statement_timeout` and you will catch the blunt version of this. The libpq [`options` connection parameter](https://www.postgresql.org/docs/current/libpq-connect.html) carries command-line switches in the startup packet, those switches are applied after the per-role settings have been loaded, and the result is a session that never issues a single statement about its own limits. ``` postgresql://agent@localhost:5432/t2?options=-c%20statement_timeout%3D0%20-c%20default_transaction_read_only%3Doff ``` Nothing changed on the server, and `rolconfig` still reads exactly as it did above. What the session reports about itself is different in both rows that matter. ``` SELECT name, setting, source, context FROM pg_settings WHERE name = 'statement_timeout'; name | setting | source | context -------------------+---------+--------+--------- statement_timeout | 0 | client | user (1 row) SHOW default_transaction_read_only; default_transaction_read_only ------------------------------- off (1 row) \timing on Timing is on. SELECT pg_sleep(4); pg_sleep ---------- (1 row) Time: 4094.391 ms (00:04.094) ``` The four-second sleep ran for 4.094 seconds where the plain connection had it cancelled at 2.060. Nothing needs to be URL-encoded into a DSN either, since the same startup packet is built from an environment variable that libpq-based clients read without being asked, psql and psycopg among them. ``` $ PGOPTIONS="-c statement_timeout=0" psql "postgresql://agent@localhost:5432/t2" 0|client ``` A review that reads the role definition and finds `statement_timeout=2s` is reading a true fact. Of the two bypasses, this is the one worth worrying about, because nobody is obliged to keep that value past the startup packet. ## USERSET against SUSET, and why no grant locks a timeout PostgreSQL 15 introduced `GRANT SET ON PARAMETER`, and the natural next thought is to revoke the right instead of granting it. More than one hardening thread gives exactly that advice. It fails for a reason the catalog states plainly, since parameter ACLs exist to hand a non-superuser a parameter it could not otherwise touch, which leaves the mechanism with nothing to say about a parameter every role may already set. ``` SELECT name, context FROM pg_settings WHERE name IN ('statement_timeout','lock_timeout','shared_buffers','session_replication_role'); name | context --------------------------+------------ shared_buffers | postmaster session_replication_role | superuser <-- SUSET lock_timeout | user <-- USERSET statement_timeout | user <-- USERSET ``` A revoke aimed at a USERSET parameter is accepted, and being accepted is all it does. ``` REVOKE SET ON PARAMETER statement_timeout FROM agent11; -> REVOKE REVOKE SET, ALTER SYSTEM ON PARAMETER statement_timeout FROM PUBLIC; -> REVOKE SELECT parname, paracl FROM pg_parameter_acl; -> (0 rows) -- nothing recorded -- agent11 still sets it: SET statement_timeout = 0; SHOW statement_timeout; -> 0 ``` Grant and then use the same privilege on a superuser-context parameter and the catalog behaves, which is the control that makes the negative result above trustworthy rather than a typo. ``` -- default: agent11 cannot set a superuser-context GUC SET session_replication_role = replica; ERROR: permission denied to set parameter "session_replication_role" -- grant SET on that parameter, and now it can GRANT SET ON PARAMETER session_replication_role TO agent11; SET session_replication_role = replica; SHOW session_replication_role; -> replica ``` `session_replication_role` is a lever with consequences of its own, and [what replica mode does not switch off](/blog/session-replication-role) goes through them with the same kind of transcript. For the question at hand it is only the control case. By definition USERSET already grants what the revoke is trying to take away, which is why the catalog has nowhere to record it. The documentation puts the same limit on the privilege, which can be granted on any parameter and bites only where superuser privilege would otherwise be needed. ## Five timeouts, and which of them end a session Teams reach for timeouts because they sound like a stop. Two of the five only cancel the running statement, and a client that keeps its connection open carries on with the next one. ![The five PostgreSQL session timeouts behind the usual postgres kill switch, split into the two that cancel a statement and leave the connection alive and the three that terminate the session, each shown with its SQLSTATE](/blog/postgres-role-for-ai-agent-3.svg) Each timeout below was set to one second and deliberately tripped, with `\set VERBOSITY verbose` on so the SQLSTATE is visible, and the line after the trigger tests whether the connection is still there. Once a code like `57014` turns up in a log with no message beside it, [the Postgres error code lookup](/tools/postgres-error-code-lookup) gives back the condition name it belongs to. | Timeout | SQLSTATE | Message begins with | Effect | Connection | |--------------------------------------|----------|----------------------------|-------------------|------------| | statement_timeout | 57014 | canceling statement | cancel STATEMENT | survives | | lock_timeout | 55P03 | canceling statement | cancel STATEMENT | survives | | idle_in_transaction_session_timeout | 25P03 | terminating connection | kill SESSION | lost | | idle_session_timeout | 57P05 | terminating connection | kill SESSION | lost | | transaction_timeout (PG17+) | 25P04 | terminating connection | kill SESSION | lost | What the split decides is whether the agent gets a new turn. `statement_timeout` is a throttle that returns control to whoever is driving the connection, so the agent gets its error and tries something shorter. ``` ERROR: 57014: canceling statement due to statement timeout after ------------------ session_survived <-- statement cancelled, SESSION ALIVE ``` `transaction_timeout` is the one people expect to behave like a bigger `statement_timeout`, and it does not. It arrived in PostgreSQL 17 to bound the total duration of a transaction, which is a different quantity from the duration of a statement, and when it fires the level is FATAL. ``` BEGIN FATAL: 25P04: terminating connection due to transaction timeout server closed the connection unexpectedly error: connection to server was lost <-- SESSION TERMINATED ``` The gap that leaves on 16 is not covered by any combination of the other four. A transaction made of short statements never trips `statement_timeout`, and if the gaps between them are server-side work rather than client idle time, `idle_in_transaction_session_timeout` never sees an idle transaction either. The measured workload used `pg_sleep` for its gaps, so it ran as long as it liked. Six seconds of one-second statements under `statement_timeout = 2s` finished and committed on both 16 and 17. ``` made_it: reached_end_of_transaction COMMIT proof: after_commit_still_connected ``` On 16 the newer knob is not there to be set, at connection time or afterwards. ``` SELECT name FROM pg_settings WHERE name='transaction_timeout'; -> (0 rows) SET transaction_timeout = '3s'; -> ERROR: unrecognized configuration parameter "transaction_timeout" # at connect via PGOPTIONS: FATAL: unrecognized configuration parameter "transaction_timeout" ``` All five are USERSET on every version where they exist. Ending a session is not much of a stop when the next connection can leave the parameter out of its own startup packet. ## What the published read-only recipes do and do not stop Two recipes went through the same probe set, applied verbatim with only the object names adapted. The first is Crunchy Data's [read-only user](https://www.crunchydata.com/blog/creating-a-read-only-postgres-user) from 2021, which grants `SELECT` on all tables, `USAGE` on all sequences, `EXECUTE` on all functions and default privileges for future tables and functions, then closes by recommending `pg_read_all_data` on PostgreSQL 14 and later as the easier replacement for that last part. The second, datamcp's [Postgres AI access security kit](https://datamcp.app/resources/postgres-ai-access-security-kit), is tighter by design. Its [kit file](https://datamcp.app/data/postgres-ai-access-security-kit.sql) grants `SELECT` on an explicit relation list, three tables here, with owner-scoped default privileges, sets `statement_timeout`, `idle_in_transaction_session_timeout` and `default_transaction_read_only` per database, and gives the role itself `CONNECTION LIMIT 5`. The criticism you will meet first is that a read-only role can still shell out. That criticism is wrong here, and the transcript says so in the server's own words. ``` -- [COPY TO PROGRAM] needs pg_execute_server_program psql:/tmp/t3-probe-scraper.sql:6: ERROR: permission denied to COPY to or from an external program DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program. HINT: Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone. -- [COPY FROM STDIN] needs INSERT psql:/tmp/t3-probe-scraper.sql:8: ERROR: permission denied for table stock_data -- [client-side \copy TO] only needs SELECT COPY 2 ``` `COPY ... TO PROGRAM`, `COPY ... FROM STDIN` and the large-object functions probed, `lo_export`, `lo_import` and `lo_get`, were correctly refused to both roles. What got through is the humble one the HINT advertises. Client-side `\copy ... TO` is a `SELECT` whose rows land in a file on the machine running the client, it needs no privilege beyond the read you granted on purpose, and no read-only grant closes it. Exfiltration through a privilege granted on purpose is a different problem from a server compromise, and it has a different fix. The role decides what can be read, so narrow the read grant to what the agent needs, and where the copied rows then land is a question for the host the client runs on. The same run turned up two results that argue with the names on the things they test. `pg_read_all_data` does not reach large objects, whatever the name of the predefined role suggests. ``` -- [lo_get] read large object contents psql:/tmp/t3-probe-scraper.sql:17: ERROR: permission denied for large object 44444 -- [nextval] advance a sequence (a write) nextval --------- 2 (1 row) ``` The `nextval` in that same block is the other one. `USAGE ON ALL SEQUENCES` carries `nextval`, which advances sequence state, so a role built this way changes no row and can still move a counter. Reading the recipe, that reach covers only sequences that existed when it ran, since its default privileges name tables and functions. Crunchy names the opposite property themselves, that `pg_read_all_data` gives read on any object created by any user in the database. The runs confirm it, including on a table created afterwards by a role the grant never mentioned. Neither recipe can bind what PostgreSQL does not let a role bind. The second sets `statement_timeout`, `idle_in_transaction_session_timeout` and `default_transaction_read_only` as defaults, all three USERSET, so the session lifts them, and the kit labels that block as operational limits that reduce impact without replacing privileges. Dave Page's [AI Features in pgAdmin: The AI Chat Agent](https://www.pgedge.com/blog/ai-features-in-pgadmin-the-ai-chat-agent) from 10 March 2026 puts the boundary somewhere the session cannot reach, describing an agent whose query tool "runs exclusively within a `READ ONLY` transaction". That boundary sits in the client, which is a different construction from the one measured here, since the process that opened the transaction fixed its access mode before the model supplied any statement text. The piece is equally straightforward that the assistant writes modification SQL for a human to run instead of running it. Contrast that with the boundary built inside a SQL validator. The recipes above do not work that way, since a grant is checked by the server on every statement. [CVE-2026-87911](https://github.com/awslabs/mcp/security/advisories/GHSA-fph8-pg5w-78fv), published on 9 September 2026 and scored 9.6 by its assigner, was a read-only enforcement bypass in the SQL validation component of `awslabs.postgres-mcp-server` before 1.1.7, reached by a crafted `COPY ... TO PROGRAM` placed in content the server processes. Its advisory notes that a role holding neither superuser nor `pg_execute_server_program` is unaffected, because the database denies the operation whatever the application layer decided, which is the refusal the transcript above produced. A different tool lost its restricted mode five days earlier, on 4 September 2026. [CVE-2026-85620](https://www.vulncheck.com/advisories/postgres-mcp-pro-0.3.0-restricted-mode-bypass-via-from-clause-function) turns on function-name validation that never reached the `RangeFunction` nodes a `FROM` clause can carry, so `pg_read_file` went into Postgres MCP Pro (`postgres-mcp`) 0.3.0 and earlier through a syntax the checker was not walking. Neither is a PostgreSQL bug. In both, the thing deciding what a statement may do was a parser that had to keep agreeing, forever, with the parser inside the server, and one crafted statement was enough to show where the two had drifted apart. ## Building a Postgres role for AI agent sessions that holds Everything that survived contact in these containers has one property in common. It is evaluated somewhere the session cannot reach, either before the session exists or in a different session entirely. `CONNECTION LIMIT` is a role attribute, stored in `pg_authid.rolconnlimit`, and checked at authentication time. With the limit at 2 and two sessions open, the third login is refused before any SQL happens at all. ``` psql: error: connection to server at "localhost" (::1), port 5432 failed: FATAL: too many connections for role "agent" ``` There is no `SET` that touches it, and the agent cannot rewrite the attribute on itself either, which is the shape every real control in this article has. ``` ALTER ROLE agent CONNECTION LIMIT 100; ERROR: permission denied to alter role DETAIL: Only roles with the CREATEROLE attribute and the ADMIN option on role "agent" may alter this role. ``` Object grants hold the same way, with one trap that is worth ten minutes of anybody's time. A least privilege Postgres role can inherit a predefined role through a chain of group memberships nobody remembers building, and the tooling most operators reach for will not show it, since `\du` dropped its Member-of column in PostgreSQL 16 and `\drg` prints one hop per row and leaves the walking to you, which is why the answer has to come out of a query. ``` rolname | can_run_programs | can_read_files | can_write_files ----------+------------------+----------------+----------------- agent | f | f | f agent4 | t | f | f postgres | t | t | t (3 rows) grantee | path -----------+--------------------------------------------------------------- etl_tools | etl_tools -> pg_execute_server_program analytics | analytics -> etl_tools -> pg_execute_server_program agent4 | agent4 -> analytics -> etl_tools -> pg_execute_server_program (3 rows) ``` `agent4` has no attributes, no direct grants worth the name, and full `COPY ... TO PROGRAM` through two hops of inherited membership. The first result came from `pg_has_role(r.oid, 'pg_execute_server_program', 'USAGE')` over `pg_roles`, which follows the whole graph, and the second from a recursive CTE over `pg_auth_members` that prints the path so you can see which group to detach. Run both against every login role before you believe a role inventory. Not every grant in that inventory was written by a person. PostgreSQL 15 removed `PUBLIC`'s `CREATE` on the `public` schema, which is the step that decides whether a role with no schema grants of its own can still create objects, and a cluster migrated onto 15 or later keeps whatever ACL it arrived with. Read `nspacl` to find out which one you inherited, since a `=UC/` entry is `PUBLIC` holding `USAGE` and `CREATE` while `=U/` is `USAGE` alone. Which of the two you end up with turns on how the database was carried across, and [the data-only restore report](/blog/pg-dump-data-only-restore) covers the neighbouring case of a `pg_dump` preamble deciding something for the session that restores it. ### The watchdog lives in another session The actual kill switch is a privileged connection that is not the agent's. Because it makes its decision out of `pg_stat_activity` and acts from its own backend, nothing the agent does to its own parameters reaches it. Between the two functions it can call sits the same cancel-or-terminate split the timeouts have. ``` === [1] find agent running first pg_sleep === pid | usename | state | q -----+---------+--------+----------------------- 774 | agent | active | SELECT pg_sleep(120); === [2] pg_cancel_backend (cancel the STATEMENT) === pid | cancelled -----+----------- 774 | t === [3] session still alive? now running statement (3), the 2nd sleep === pid | usename | state | q -----+---------+--------+----------------------- 774 | agent | active | SELECT pg_sleep(120); <-- SAME pid, still connected === [4] pg_terminate_backend(pid) WITHOUT timeout === pid | terminated -----+------------ 774 | t === [5] sessions after terminate === agent_sessions ---------------- 0 ``` Step 3 shows the same PID as step 1, already on its second long statement, after a cancel that an operator could easily have written into a log as a stop. `pg_terminate_backend` ended it. The two-argument form is the one to write into a watchdog, because the single-argument call returns as soon as SIGTERM is delivered and tells you nothing about whether the backend has actually gone. ``` -- 1-arg: fire-and-return pid | pg_terminate_backend -----+---------------------- 826 | t Time: 4.080 ms -- 2-arg pg_terminate_backend(pid, 5000): waited for the process to actually exit pid | terminated_confirmed -----+---------------------- 840 | t Time: 104.235 ms ``` The first call came back in four milliseconds and the second in a hundred and four, with the target dead either way. Those hundred milliseconds are the watchdog waiting for the backend to actually leave `pg_stat_activity`, which is what you want its log line to be asserting. Terminating is not the end of it either, because the agent still holds working credentials and its driver reconnects. Take the login away first, with `ALTER ROLE agent NOLOGIN` or `CONNECTION LIMIT 0`, and the terminate that follows stops the agent instead of interrupting it. Then there is the matter of how the watchdog finds its target, and the obvious choice is the wrong one. `application_name` is USERSET like everything else in this article, so a session that connected as `seed-agent` renames itself with one `SET` and drops out of the filter. ``` -- filter on the name it connected with: SELECT pid, usename, application_name FROM pg_stat_activity WHERE application_name = 'seed-agent'; pid | usename | application_name -----+---------+------------------ (0 rows) <-- the watchdog's filter now finds NOTHING -- ground truth, filter on the role instead: SELECT pid, usename, application_name FROM pg_stat_activity WHERE usename = 'agent'; pid | usename | application_name -----+---------+------------------ 958 | agent | psql <-- the session is alive and well, under a name of its own choosing ``` Filter on `usename`, which is recorded at authentication and is not a parameter, or on `datname` and `client_addr`. A watchdog cannot tell two agents apart when they share a login, which is the argument for giving every identity that connects its own login role, whether that is a coding agent or a data generator such as Seedfast. ## Where to put the boundary Nothing that lives in the session binds the session. Put `statement_timeout` on the role and you have given well-behaved clients a sensible default and accidents somewhere to stop; `default_transaction_read_only` buys the same thing, and `application_name` was only ever a label. An agent that wants any of them gone spends one statement on it, and the agent that edits its connection string before connecting never writes that statement down anywhere. The controls that did bind were all evaluated somewhere else. Role attributes are read before the session exists, which is why `CONNECTION LIMIT` and `NOLOGIN` mean what they say, and `pg_hba.conf` is earlier still, since it decides which database and which source address the role may reach before any SQL is parsed. Object grants get their answer from the server on every statement, while a validator standing in front of the server only ever gets to agree with it, and that is the difference the two September advisories turn on. The watchdog needs a connection of its own and a filter on `usename`, and once it has taken the login away it can finish with `pg_terminate_backend(pid, timeout_ms)`. Where a `\copy` may write its file stays a question for the host the client runs on. One boundary of this kind is not a role attribute at all. On a hot standby, recovery enforces read-only below the level a parameter can reach, so `SET default_transaction_read_only = off` and a connection string full of `options` change nothing, and the documentation says such connections are strictly read-only down to temporary tables, which may not be written either. Crunchy's post opens by naming that same option before it reaches any `GRANT`, and for an agent that only has to read, pointing it at a replica retires most of the argument above. Write the timeouts anyway; a default that stops an accident earns its place. What the log line should carry is the value the session is holding when the statement runs, since the role's copy of it stopped being evidence the moment the session connected. The postgres kill switch stays where the agent has no handle on it, in a session it did not open and cannot see the parameters of. ## Frequently asked questions ### Can I stop an AI agent's role from changing statement_timeout? Not with a grant. `statement_timeout` has `context = user`, which means every role may set it in its own session, and `REVOKE SET ON PARAMETER statement_timeout` is accepted while writing no row to `pg_parameter_acl` and changing no behaviour. The same applies to `transaction_timeout`, `lock_timeout`, both idle timeouts, `default_transaction_read_only`, `search_path` and `application_name`. What you can do is enforce the limit from outside, with a watchdog session that reads `pg_stat_activity` and terminates backends that outstay the policy. ### Does a read only Postgres user stop data leaving the database? It stops table writes and it does nothing about reads leaving, which is the half people assume it covers. It does not always stop every write either, since the Crunchy role is granted `USAGE ON ALL SEQUENCES` and that carries `nextval`. Client-side `\copy ... TO` is an ordinary `SELECT` whose rows are written to a file by the client, so any role that can read a table can put that table in a file, and the server says as much in its own HINT on the `COPY` error. Server-side `COPY ... TO PROGRAM` and the large-object functions probed were correctly refused to both published read-only roles tested here. Once the read is granted the rows are readable, so the control that is left lives on the machine the client runs on and in what that machine can reach. ### What is the difference between pg_cancel_backend and pg_terminate_backend? `pg_cancel_backend(pid)` cancels the statement currently running and leaves the connection open, so the session receives an error and carries on with whatever it wants to run next. `pg_terminate_backend(pid)` ends the backend, and the client sees `FATAL: terminating connection due to administrator command`. Prefer the two-argument `pg_terminate_backend(pid, timeout_ms)` in automation, since it waits for the process to actually exit and its true return means the session is gone rather than signalled. ### Is transaction_timeout available on PostgreSQL 16? No, it was added in 17. On 16 the parameter is not in `pg_settings`, setting it raises `unrecognized configuration parameter`, and passing it at connection time makes the connection fail outright. That leaves a real gap, because a transaction built from short statements never trips `statement_timeout`, and when its gaps are server-side work rather than client idle time `idle_in_transaction_session_timeout` never fires either, so its total duration is unbounded on 16 by any per-role parameter. ## Related guides - [PgBouncer transaction mode: what the next client inherits](/blog/pgbouncer-transaction-mode). The cap this article puts on a role travels to the next caller through a transaction-mode pooler, and the connection-string route stops at the pooler's door. - [Forty deletes vanished from pg_stat_statements](/blog/pg-stat-statements-missing-queries). Why the role matters for reading the statistics afterwards, since the view groups by role and holds no column for `application_name`. - [What replica mode does not switch off](/blog/session-replication-role). The other side of `GRANT SET ON PARAMETER`, and what a superuser-context parameter actually suspends. - [The dump that breaks its own restore](/blog/pg-dump-data-only-restore). What else a `pg_dump` preamble decides for the session that restores it. - [Keeping an agent off the database that matters](/blog/ai-agent-production-database). The blast-radius argument for giving an agent a disposable Postgres instead of a hardened role on the real one. - [PostgreSQL test data: a syntax cookbook](/blog/test-data-postgresql). `generate_series`, `\copy` and the rest of the syntax a throwaway database needs. --- # What Replica Mode Does Not Switch Off > Source: https://seedfa.st/blog/session-replication-role > Last updated: 2026-09-04 An overnight load runs under `session_replication_role = replica`, the setting most of the popular answers describe as switching enforcement off for the session. It writes an order for customer 999, who does not exist, and then rejects the next row for having a negative amount. ``` SET session_replication_role = replica; SET INSERT INTO orders (customer_id, amount, tenant_id) VALUES (999, 10, 1); INSERT 0 1 INSERT INTO orders (customer_id, amount, tenant_id) VALUES (1, -5, 1); ERROR: new row for relation "orders" violates check constraint "orders_amount_check" ``` Both statements ran one line apart in the same session, against the same table. The parameter governs which triggers and rules fire, and a foreign key is enforced by a pair of internal triggers that `pg_trigger` names `RI_ConstraintTrigger_c_*`, so the foreign key falls silent along with them. No trigger implements a CHECK constraint, which is why that one carries on rejecting rows. ![Two columns comparing what session_replication_role = replica switches off, including foreign key checks and ON DELETE actions, against what it leaves enforced, including CHECK, NOT NULL, UNIQUE and row-level security](/blog/session-replication-role-2.svg) Two more levers get recommended for the same job. `ALTER TABLE ... DISABLE TRIGGER USER` (or `ALL`) works on the table instead of the session, and PostgreSQL 18 added `ALTER TABLE ... ALTER CONSTRAINT ... NOT ENFORCED`, which works on a single constraint. All three went through one identical probe set below, on a schema built to carry every kind of rule at once. ## Key Takeaways - Under `session_replication_role = replica` a load still meets CHECK, NOT NULL, UNIQUE, identity columns and row-level security. Foreign keys, `ON DELETE CASCADE`, rules and event triggers go quiet, and a trigger marked `ENABLE REPLICA` fires for the first time in its life. - Neither `RESET` nor `ENABLE TRIGGER ALL` reads a row on the way back, and `VALIDATE CONSTRAINT` against a foreign key the catalog already believes is valid answers `ALTER TABLE` while orphans sit in the table. - `NOT ENFORCED`, added for foreign keys in PostgreSQL 18 and extended to CHECK constraints in 19, is the only lever that scans the table when you switch it back on, and a forced row-level security policy can hide rows from that scan. Every transcript below came out of throwaway containers on 2026-09-04, PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) and PostgreSQL 19beta3 (Debian 19~beta3-1.pgdg13+1), pasted as it came back. ## What session_replication_role switches off, and what it keeps The table under test, `orders`, carries an identity primary key, a foreign key to `customers` with `ON DELETE CASCADE`, a CHECK on the amount, a stored generated column, a `now()` default and four NOT NULL columns, and on top of that sit three AFTER INSERT triggers (one in the default state, one `ENABLE ALWAYS`, one `ENABLE REPLICA`), an INSERT rule, and row-level security enabled and forced behind a tenant policy. `customers` adds a `GENERATED ALWAYS` identity and a UNIQUE email, an event trigger on `ddl_command_start` watches the database from above, and everything runs as `loader`, a non-superuser role that owns the schema. Under `replica`, more probes came back rejected than got through. Duplicate emails and explicit identity values were refused with the messages they give at the baseline, NOT NULL held, and the cross-tenant row was refused by the policy. ``` INSERT INTO customers (email, tier, tenant_id) VALUES ('ana@example.test', 'free', 1); ERROR: duplicate key value violates unique constraint "customers_email_key" DETAIL: Key (email)=(ana@example.test) already exists. INSERT INTO customers (id, email, tier, tenant_id) VALUES (42, 'cy@example.test', 'free', 1); ERROR: cannot insert a non-DEFAULT value into column "id" DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS. HINT: Use OVERRIDING SYSTEM VALUE to override. ``` On the valid row that closed each run, the stored generated column computed 1250 from an amount of 12.5, the `now()` default landed, and the sequence behind `orders` moved 5 to 6 exactly as it had at the baseline. The mode announces itself in the trigger log instead, which three sources write to, and one baseline insert produces this. ``` SELECT source FROM order_log ORDER BY id; source --------------------------- trigger, ENABLE ALWAYS trigger, default (ORIGIN) rule, default (ORIGIN) (3 rows) ``` The same query after a `replica` run of the same probes reads differently in every line. ``` SELECT source FROM order_log ORDER BY id; source ------------------------- trigger, ENABLE ALWAYS trigger, ENABLE REPLICA trigger, ENABLE ALWAYS trigger, ENABLE REPLICA (4 rows) ``` Two inserts produced four rows, because the orphan that the foreign key would have blocked also landed and also logged, while the default trigger stopped firing along with the rule and `trg_replica`, marked `ENABLE REPLICA` on the day the schema was created and dormant ever since, fired twice. Event triggers follow the same rule, and at the baseline DDL announces itself. ``` CREATE TABLE scratch (x int); NOTICE: event trigger fired on CREATE TABLE CREATE TABLE ``` Under `replica` that NOTICE is absent and `CREATE TABLE` comes back on its own, which means an audit or a guard rail written as an event trigger is off duty for the length of the load without saying so. Referential actions are triggers too, so the delete is where the cost shows up. Customer `bo` has one order, and removing `bo` at the baseline takes the order with it. ``` DELETE FROM customers WHERE email = 'bo@example.test'; DELETE 1 SELECT count(*) AS bo_orders_left FROM orders WHERE customer_id = 2; bo_orders_left ---------------- 1 (1 row) ``` The parent went and the child stayed behind, with nothing said to the session that ran the delete, so two orphans now sit in `orders`, the one the cascade left behind and the one that came in against customer 999. ## The second session, and the way back Because the mode is a session GUC, a second connection to the same database that has set nothing of its own is still enforcing, right through the load. ``` INSERT INTO orders (customer_id, amount, tenant_id) VALUES (998, 10, 1); ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key is not present in table "customers". ``` That containment is the parameter's best property and the reason the lever belongs in a restore pipeline rather than in a shell somebody happens to be typing in. `pg_settings` reports its context as `superuser`, and since PostgreSQL 15 the privilege can be handed to a single role with `GRANT SET ON PARAMETER session_replication_role TO loader`. The refusal a role meets without that grant, along with the restore case the whole lever exists for, is in [the data-only restore report](/blog/pg-dump-data-only-restore). The accounting gets thin on the way back out. `RESET session_replication_role` re-checks nothing, which surprises nobody, but `pg_constraint` goes on reporting `convalidated t` and `conenforced t` for every constraint on `orders` while those two orphans sit there, and the statement people reach for to settle their nerves settles nothing at all. ``` ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_fkey; ALTER TABLE SELECT o.id, o.customer_id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL ORDER BY o.id; id | customer_id ----+------------- 1 | 2 2 | 999 (2 rows) ``` `VALIDATE CONSTRAINT` is defined against constraints marked `NOT VALID`, and handed one that PostgreSQL already considers valid it returns success without reading a row. Finding the damage takes the anti-join above, written by you, against the tables you touched, and a schema-only `pg_dump` taken afterwards says nothing about replication anywhere, correctly, since nothing in the schema was ever changed. ## DISABLE TRIGGER USER against DISABLE TRIGGER ALL ![The three enforcement levers side by side, session_replication_role against ALTER TABLE DISABLE TRIGGER and ALTER CONSTRAINT NOT ENFORCED, compared by scope, required privilege, what gets re-checked and what pg_dump keeps](/blog/session-replication-role-3.svg) The second lever writes to the catalog, so its effect outlives the session that set it and reaches everyone connected. Run as the owner, `ALTER TABLE orders DISABLE TRIGGER USER` leaves the foreign key fully armed, since `USER` skips internal triggers by definition, and the orphan insert is rejected exactly as at the baseline. Both application triggers go quiet, the `ENABLE ALWAYS` one included, which is precisely the trigger `replica` could not reach, and one writer survives. ``` SELECT source FROM order_log ORDER BY id; source ------------------------ rule, default (ORIGIN) (1 row) ``` Rules are rewrite-time machinery, so no spelling of `DISABLE TRIGGER` touches them, and under `replica` that same rule had been the one to fall silent. Each lever leaves a different set of side effects running, which is worth knowing before you switch from one to another halfway through a load. `ALL` belongs to a different privilege class, and the owner of the table is refused. ``` ALTER TABLE orders DISABLE TRIGGER ALL; ERROR: permission denied: "RI_ConstraintTrigger_c_16420" is a system trigger ``` A superuser gets `ALTER TABLE`, after which `pg_trigger.tgenabled` reads `D` for all five triggers on `orders`, the two internal RI ones included, against a baseline of `O` for the default trigger, `A` for always and `R` for replica. The orphan now goes in for everybody, and the second connection that was rejected in the previous section gets `INSERT 0 1` on the identical statement. Cascades are the trap in this one. Switching off every trigger on `orders` does not stop `ON DELETE CASCADE` from deleting `orders` rows, because the triggers that carry out the action live on the parent side of the constraint. ``` SELECT tgrelid::regclass AS on_table, tgname, tgenabled FROM pg_trigger WHERE tgconstraint = (SELECT oid FROM pg_constraint WHERE conname = 'orders_customer_id_fkey') ORDER BY 1, 2; on_table | tgname | tgenabled -----------+------------------------------+----------- customers | RI_ConstraintTrigger_a_16418 | O customers | RI_ConstraintTrigger_a_16419 | O orders | RI_ConstraintTrigger_c_16420 | D orders | RI_ConstraintTrigger_c_16421 | D (4 rows) ``` Deleting `bo` in that state emptied his orders, the way it does at the baseline, which is the opposite of what happens under `replica` with nothing disabled at all. A schema-only `pg_dump` then carries half the arrangement forward, in three lines. ``` ALTER TABLE public.orders DISABLE TRIGGER trg_always; ALTER TABLE public.orders DISABLE TRIGGER trg_origin; ALTER TABLE public.orders DISABLE TRIGGER trg_replica; ``` Nothing in that dump mentions the internal RI triggers, so the file describes a database whose foreign keys enforce while its application triggers stay switched off, a combination nobody chose, and re-enabling reads no data on the way back either. ``` ALTER TABLE orders ENABLE TRIGGER ALL; ALTER TABLE SELECT o.id, o.customer_id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL ORDER BY o.id; id | customer_id ----+------------- 7 | 999 12 | 998 (2 rows) ``` In the container, `DISABLE TRIGGER USER` held a `ShareRowExclusiveLock` on the table for the length of its transaction. ## NOT ENFORCED, one constraint at a time PostgreSQL 18 made enforceability a property you can toggle, documented for foreign keys, and PostgreSQL 19 extends the same syntax to CHECK constraints. On 18.6 the foreign key accepts it while the CHECK is refused with `cannot alter enforceability of constraint "orders_amount_check" of relation "orders"`. On 19beta3 the CHECK accepts it, a row with an amount of -5 goes straight in, and the refusal for NOT NULL spells out the rule. ``` ALTER TABLE orders ALTER CONSTRAINT orders_amount_not_null NOT ENFORCED; ERROR: cannot alter enforceability of constraint "orders_amount_not_null" of relation "orders" HINT: Only foreign key and check constraints can change enforceability. ``` While the constraint is off the catalog is honest about it, with `conenforced` and `convalidated` both `f` and `\d orders` printing `ON DELETE CASCADE NOT ENFORCED` on the foreign-key line. Scope here is the constraint, so every session sees it, `ON DELETE CASCADE` stops running as it does under `replica`, and the dump keeps the state. ``` ADD CONSTRAINT orders_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id) ON DELETE CASCADE NOT ENFORCED; ``` On 19beta3 a switched-off CHECK reaches the dump the same way, as `ADD CONSTRAINT orders_amount_check CHECK ((amount > (0)::numeric)) NOT ENFORCED;`. Switching back is the one route of the three that reads the data. With an orphan in the table and the policy out of the way, `ENFORCED` scans and stops. ``` ALTER TABLE orders ALTER CONSTRAINT orders_customer_id_fkey ENFORCED; ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(999) is not present in table "customers". ``` The constraint stays at `conenforced f` afterwards, so the failure is clean rather than half applied, and the key it names is the row to go and fix. For a CHECK constraint on 19beta3 the message is `check constraint "orders_amount_check" of relation "orders" is violated by some row`, with no row named. While enforcement is off, `VALIDATE CONSTRAINT` answers `cannot validate NOT ENFORCED constraint`, and adding a constraint `NOT VALID` is a genuinely different bit, since it reads `conenforced t, convalidated f` and enforces against new rows while nobody has vouched for the old ones. On both directions of the toggle an `AccessExclusiveLock` was taken in the container, which makes this the lever least suited to a live table. ## Row-level security survives all three Nothing above switched off the policy, and the cross-tenant insert was rejected under `replica`, under both spellings of `DISABLE TRIGGER`, and with the foreign key `NOT ENFORCED`. ``` INSERT INTO orders (customer_id, amount, tenant_id) VALUES (1, 10, 2); ERROR: new row violates row-level security policy for table "orders" ``` Bulk loading meets its own wall here, well before any lever is involved, because `COPY FROM` into a table with RLS forced, run by the owner, is refused outright. ``` COPY orders (customer_id, amount, tenant_id) FROM STDIN; ERROR: COPY FROM not supported with row-level security HINT: Use INSERT statements instead. ``` With RLS switched off on the table, `COPY` under `replica` behaves like `INSERT`, taking a row whose customer does not exist and stopping on the negative amount with `new row for relation "orders" violates check constraint "orders_amount_check"`. The last observation needs its scoping read before its result. The table has row-level security enabled and forced, the policy compares `tenant_id` against `current_setting('app.tenant', true)::int`, and the owner of the table runs every statement. Session A sets `app.tenant`, marks the foreign key `NOT ENFORCED` and inserts an orphan, leaving two rows in `orders`. Session B is a fresh connection that never set `app.tenant`, so `current_setting('app.tenant', true)` is NULL, the policy evaluates to NULL for every row, and the table looks empty to it. ``` SELECT count(*) AS rows_this_session_can_see FROM orders; rows_this_session_can_see --------------------------- 0 (1 row) ALTER TABLE orders ALTER CONSTRAINT orders_customer_id_fkey ENFORCED; ALTER TABLE ``` The scan raised no error, and `pg_constraint` now reports `conenforced t, convalidated t`. When session C sets the tenant again, the orphan is still there and the constraint is doing its job on new rows. ``` SELECT o.id, o.customer_id FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL; id | customer_id ----+------------- 2 | 999 (1 row) INSERT INTO orders (customer_id, amount, tenant_id) VALUES (998, 10, 1); ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key is not present in table "customers". ``` From that moment on new rows are checked, and the row already there was never looked at. Those same three sessions with a plain `ADD CONSTRAINT ... NOT VALID` followed by `VALIDATE CONSTRAINT` end identically, and both sequences behaved the same way on 18.6 and on 19beta3. The controls matter as much as the result, so take `FORCE` off the table with `ALTER TABLE orders NO FORCE ROW LEVEL SECURITY`, which restores the owner's ordinary bypass, and the same `ENFORCED` fails on customer 999, as it does when a superuser runs it. Asking for `SET row_security = off` produces `query would be affected by row-level security policy for table "orders"`, and using `RESET app.tenant` in the same session instead of opening a fresh one leaves the placeholder as an empty string, so the scan errors with `invalid input syntax for type integer: ""`. [The documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) says that referential integrity checks bypass row-level security. The scan that ran here sits behind `ENFORCED` and is a different query, so the reading to take away is operational. Switch enforcement back on from a session that can see every row in the table, or lift `FORCE` for the duration, and run the anti-join yourself before you trust `convalidated`. ## Before you reach for any of them Start by asking what actually has to stop. Insert order in a schema whose foreign keys form a cycle is not an enforcement problem, since deferrable constraints hold the check until `COMMIT` without dropping it, and [the circular foreign key guide](/blog/circular-foreign-key-seed) works that through against real output. For what a foreign key promises and to whom, [referential integrity](/blog/referential-integrity) is the explainer, and a one-paragraph version of this parameter sits in [the PostgreSQL test data cookbook](/blog/test-data-postgresql), next to `generate_series` and `\copy`. Clearing the tables ahead of a reload needs none of this either, only a `TRUNCATE ... CASCADE` naming every one of them, a list [the all-tables TRUNCATE generator](/tools/postgres-truncate-all-tables) reads straight out of a schema. Where the rows are being generated rather than moved between databases, none of this has to come out. Tools like Seedfast take a different route, reading the schema and generating rows that already satisfy the constraints, so nothing has to be switched off. Any lever you do pull is worth pulling narrowly, with the anti-join written before the load rather than after it. ## Frequently asked questions ### Does session_replication_role disable foreign key checks in Postgres? It does, for the session that sets it and for nothing else. Foreign keys are enforced by internal triggers, and `replica` stops those along with every trigger sitting in the default state, plus rules and event triggers, while CHECK, NOT NULL, UNIQUE, identity columns and row-level security carry on refusing rows. A load written against the assumption that everything is off still fails, just later and on something else. ### What is the difference between DISABLE TRIGGER USER and DISABLE TRIGGER ALL? `USER` covers the triggers you wrote and leaves the internal RI triggers alone, which keeps foreign keys enforcing, whereas `ALL` includes the internal ones, and that is why the table owner is refused with `permission denied: "RI_ConstraintTrigger_c_16420" is a system trigger` while a superuser succeeds. Because both write to the catalog rather than to your session, the state persists until somebody re-enables it, and `pg_dump` carries the user half of it into the schema dump. ### Does Postgres re-check the data when enforcement goes back on? Only `ALTER CONSTRAINT ... ENFORCED` reads the rows, and it scans the table and refuses to finish when it meets a violation, naming the key it tripped over. `RESET session_replication_role` and `ENABLE TRIGGER ALL` both return without reading a row, and `VALIDATE CONSTRAINT` against a constraint that was never marked invalid reports success without doing anything. ### Can a non-superuser set session_replication_role? Not without help, since `pg_settings` gives the parameter a context of `superuser` and a plain table owner asking for `replica` is turned down. PostgreSQL 15 documented `GRANT SET ON PARAMETER`, which hands over this one parameter and nothing else, and it deserves a look before it lands on the role your application logs in as, because the session that holds it can write rows no constraint will ever see. ## Related guides - [The dump that breaks its own restore](/blog/pg-dump-data-only-restore). Where these levers show up in a restore pipeline, and what each one costs there. - [PostgreSQL test data: a syntax cookbook](/blog/test-data-postgresql). `generate_series`, `\copy` and `pg_dump --data-only`, with the one-paragraph version of this parameter. - [Circular foreign key seed: three workarounds that actually run](/blog/circular-foreign-key-seed). Deferrable constraints and nullable back-edges, for the schemas that have no valid load order. - [Referential integrity in PostgreSQL](/blog/referential-integrity). What the constraint promises, before you decide to suspend it. - [The Postgres insert that fails right after a successful load](/blog/postgres-sequence-out-of-sync). The other thing a bulk load leaves behind, sitting in the sequences. --- # The Dump That Breaks Its Own Restore > Source: https://seedfa.st/blog/pg-dump-data-only-restore > Last updated: 2026-08-28 A pg_dump data-only restore into a fresh, empty copy of the schema is the safest-sounding restore in PostgreSQL, since there is not a single row in the target for the incoming data to collide with. It stops anyway, on a table sitting plainly in `\dt`. ``` psql:/tmp/data.sql:56: ERROR: relation "book_audit" does not exist LINE 1: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'inser... ``` `book_audit` exists. The statement that cannot see it never appears in the dump, because it lives inside an AFTER INSERT trigger on `books`, and the reason a bare table name stops resolving is one line the dump wrote at the top of itself before any data moved. ```sql SELECT pg_catalog.set_config('search_path', '', false); ``` That pins `search_path` to the empty string for the rest of the session, and every unqualified table reference inside a trigger body goes down with it. The dump breaks its own restore. ![A pg_dump data-only restore failing on the pinned search_path preamble, where the trigger on books cannot resolve the unqualified name book_audit and the whole COPY rolls back](/blog/pg-dump-data-only-restore-2.svg) Folklore warns about a different failure here, the one where the trigger fires successfully during the `books` load and collides with the audit rows the dump is putting back. That failure is real. You meet it second, and only in a session whose triggers can still resolve their own tables. Reach for one of three levers at this point. `--disable-triggers` bakes trigger suspension into the file, one transaction with `SET CONSTRAINTS ALL DEFERRED` fixes load order, and `session_replication_role = replica` switches enforcement off for the session. Two of the three bill you in a currency nobody mentions until the restore is half done. ## Key Takeaways - The dump's own preamble pins `search_path` to the empty string, so a trigger function that names its own tables without a schema breaks the restore with `relation ... does not exist`, even into a target holding zero rows. - A trigger error rolls back the entire `COPY` it interrupted, leaving that table at zero rows while every other table in the file loads normally around it. - The trailing `setval` calls fire whether or not the matching table loaded, so a sequence can sit at 5 above a table holding nothing. Everything below was run against PostgreSQL 18.6 in throwaway containers on 2026-08-27 and 28, and the outputs are pasted as they came back. ## What is inside a data-only dump Strip the DDL out and what remains is shorter than most people picture. Five tables produce a preamble of `SET` statements, one `COPY ... FROM stdin` block per table, and one `setval` per sequence at the bottom. The preamble's decisive line sits in the middle of ten lines of housekeeping. ```sql SET statement_timeout = 0; SET lock_timeout = 0; ... SELECT pg_catalog.set_config('search_path', '', false); ... SET row_security = off; ``` Table order looks alphabetical often enough that people assume it is. Build a schema where the two orders disagree and the question settles itself. ```sql CREATE TABLE zz_parents (id bigserial PRIMARY KEY, name text); CREATE TABLE aa_children (id bigserial PRIMARY KEY, parent_id bigint REFERENCES zz_parents(id)); ``` `pg_dump --data-only` emits `zz_parents` first and `aa_children` after it, parent ahead of child, straight against the alphabet. The `SEQUENCE SET` statements at the foot of that same file do come out alphabetically, `aa_children_id_seq` before `zz_parents_id_seq`, since nothing connects one sequence to another for a dependency sort to work on. Every sequence gets exactly one line, and the shape never varies. ```sql SELECT pg_catalog.setval('public.books_id_seq', 5, true); ``` Nothing there is conditional and nothing consults the table. In all five the third argument came back `true`. Data arrives as text `COPY` rather than `INSERT` unless you ask otherwise, and `--inserts` is the flag that moves every value into [a quoted SQL literal](/tools/postgres-escape-string) instead of a tab-separated field. The export side of all this, the `--table` and `--exclude-table-data` flags that carve a slice out of a larger database, is covered in [the PostgreSQL test data cookbook](/blog/test-data-postgresql). ## Why the pg_dump data-only restore breaks on a trigger The test schema is three tables and one trigger, roughly what every application grows within its first year. ```sql CREATE TABLE authors (id bigserial PRIMARY KEY, name text NOT NULL); CREATE TABLE books (id bigserial PRIMARY KEY, author_id bigint NOT NULL REFERENCES authors(id), title text NOT NULL); CREATE TABLE book_audit (id bigserial PRIMARY KEY, book_id bigint, note text, logged_at timestamptz DEFAULT now()); CREATE FUNCTION log_book() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen'); RETURN NEW; END $$; CREATE TRIGGER trg_book_audit AFTER INSERT ON books FOR EACH ROW EXECUTE FUNCTION log_book(); ``` Three authors and five books go in by hand, the trigger writes five `book_audit` rows of its own, and `pg_dump --data-only` picks up all thirteen without distinguishing which rows a person typed and which a trigger produced. It only ever sees table contents. Restored into a brand new database carrying the identical schema and no rows whatsoever, the transcript reads like this. ``` COPY 3 COPY 5 psql:/tmp/data.sql:56: ERROR: relation "book_audit" does not exist LINE 1: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'inser... ^ QUERY: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen') CONTEXT: PL/pgSQL function public.log_book() line 1 at SQL statement COPY 3 COPY 3 ``` Afterwards `authors` holds 3, `book_audit` holds exactly the 5 dumped rows with no doubling anywhere, and `books` holds nothing at all. Emptiness rather than a partial load is the giveaway that a `COPY` is a single statement; the trigger threw on the first of the five rows and took the statement down with it. Two tables further along, `gadgets` and `widgets` loaded fine, because neither carries a trigger and neither cared. Pinning `search_path` by hand reproduces the whole thing with no dump involved. ```sql SELECT pg_catalog.set_config('search_path', '', false); INSERT INTO books (author_id, title) VALUES (1, 'x'); ``` ``` ERROR: relation "books" does not exist LINE 1: INSERT INTO books (author_id, title) VALUES (1, 'x'); ^ ``` With `search_path` empty a bare name resolves against nothing, and `log_book()` writes `INSERT INTO book_audit(...)` rather than `INSERT INTO public.book_audit(...)`, a spelling no environment complains about until an empty search path arrives. Restore the search path and the anticipated failure turns up right where the folklore says it will. This is the same instant mid-restore, sequence still un-advanced because the `setval` lines live at the bottom of the file. ```sql SET search_path = public; INSERT INTO books (author_id, title) VALUES (1, 'x'); ``` ``` ERROR: duplicate key value violates unique constraint "book_audit_pkey" DETAIL: Key (id)=(1) already exists. CONTEXT: SQL statement "INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen')" PL/pgSQL function log_book() line 1 at SQL statement ``` A trigger function disciplined enough to schema-qualify its own tables would clear the preamble and meet this duplicate key instead. Switching the dump to `--inserts` or to the custom archive changes none of it either; the same trigger error comes back one row at a time instead of once per table. ## Restoring into a schema that already has rows Staging refreshes rarely run against an empty database, and a populated target changes which failure arrives first. This target starts with one author and one book already in place, which also means `book_audit` holds one row that its own trigger wrote. The same file goes in again with no flags at all. ``` psql:/tmp/data.sql:30: ERROR: duplicate key value violates unique constraint "authors_pkey" DETAIL: Key (id)=(1) already exists. CONTEXT: COPY authors, line 1 psql:/tmp/data.sql:43: ERROR: duplicate key value violates unique constraint "book_audit_pkey" DETAIL: Key (id)=(1) already exists. CONTEXT: COPY book_audit, line 1 psql:/tmp/data.sql:56: ERROR: duplicate key value violates unique constraint "books_pkey" DETAIL: Key (id)=(1) already exists. CONTEXT: COPY books, line 1 COPY 3 COPY 3 ``` Three tables kept the single row they came in with, `gadgets` and `widgets` took all three of theirs because nothing stood in the way, and the database now holds a mixture matching neither the source nor the target. Each incoming `COPY` died on its primary-key collision before any trigger could weigh in, which is why the search_path error is absent from this transcript. Reset to the identical starting state and add `--single-transaction`. ``` psql:/tmp/data.sql:30: ERROR: duplicate key value violates unique constraint "authors_pkey" DETAIL: Key (id)=(1) already exists. CONTEXT: COPY authors, line 1 psql:/tmp/data.sql:37: ERROR: current transaction is aborted, commands ignored until end of transaction block psql:/tmp/data.sql:50: ERROR: current transaction is aborted, commands ignored until end of transaction block ``` Nothing survived that run. `gadgets` and `widgets` rolled back with the rest even though both had loaded cleanly a moment earlier under the other mode, which is the outcome you actually want, since a target left exactly as it was needs no untangling by hand. Both runs share one number worth staring at. psql exited 0 for each of them, and zero held for every plain-format run in this report that left `-v ON_ERROR_STOP` off, which means a CI step gating on `$?` will happily pass a restore that loaded nothing. Tell psql to care with `-v ON_ERROR_STOP=1` and a script failing the same way stops at the first error, exiting 3 instead of 0. The custom format never needed the telling. ``` pg_restore: error: COPY failed for table "books": ERROR: relation "book_audit" does not exist LINE 1: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'inser... ^ QUERY: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen') CONTEXT: PL/pgSQL function public.log_book() line 1 at SQL statement pg_restore: warning: errors ignored on restore: 1 ``` `pg_restore` exits 1 there and reports how many errors it ignored, so its status is worth branching on. The archive format carries a second advantage that only shows up once a dump already exists. The file above was produced without `--disable-triggers`, and `pg_restore --data-only --disable-triggers` applied trigger suspension to it anyway, at restore time, with no re-dump and no access to the source database. A plain SQL script offers no equivalent, because there the `ALTER TABLE ... DISABLE TRIGGER` statements are static text that `pg_dump` either wrote or did not. ## The setval lines do not check anything Those trailing statements run with no conditions attached. Look at the sequence behind `books` after the failed restore from earlier, the one where the trigger emptied the table. ``` books_rows ------------ 0 (1 row) last_value | is_called ------------+----------- 5 | t ``` The table holds nothing while its sequence is confident that five ids have gone out. Nothing surfaces the disagreement, and the next insert that lets Postgres pick an id returns 6, so the table's first real row lands at 6 and the gap stays there until somebody writes a test assuming ids start at 1. Where the restore completes, the same lines land every sequence correctly, and plain inserts carry on past the loaded data with no duplicate key anywhere. The opposite failure, a load that writes explicit ids with no `setval` following it, leaves the sequence behind the table instead of ahead of it, and that one is [the out-of-sync sequence](/blog/postgres-sequence-out-of-sync). ## Three mitigations and the price of each ![Two mitigations for a pg_dump data-only restore compared side by side, --disable-triggers against a single transaction with SET CONSTRAINTS ALL DEFERRED, with what each one fixes and what it costs](/blog/pg-dump-data-only-restore-3.svg) ### `--disable-triggers` and the ownership it needs `pg_dump --data-only --disable-triggers` wraps every table's `COPY` in a pair of `ALTER TABLE` statements. ```sql SET SESSION AUTHORIZATION DEFAULT; ALTER TABLE public.authors DISABLE TRIGGER ALL; COPY public.authors (id, name) FROM stdin; 1 Ursula K. Le Guin 2 Octavia E. Butler 3 Ted Chiang \. ALTER TABLE public.authors ENABLE TRIGGER ALL; ``` `SET SESSION AUTHORIZATION DEFAULT` shows up once, ahead of the first `DISABLE TRIGGER`, not per table. Run as the superuser into an empty schema this restores cleanly, `books` takes all five rows, and `book_audit` ends with exactly the five that were dumped, because the trigger body never executes and so never gets the chance to double anything. Run the same file as the role your pipeline actually uses and the flag comes apart quietly. The role below owns nothing and holds `INSERT` on all five tables. ``` psql:/tmp/data-dt.sql:28: ERROR: must be owner of table authors COPY 3 psql:/tmp/data-dt.sql:37: ERROR: must be owner of table authors psql:/tmp/data-dt.sql:43: ERROR: must be owner of table book_audit COPY 5 psql:/tmp/data-dt.sql:54: ERROR: must be owner of table book_audit psql:/tmp/data-dt.sql:60: ERROR: must be owner of table books psql:/tmp/data-dt.sql:68: ERROR: relation "book_audit" does not exist LINE 1: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'inser... ^ QUERY: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen') CONTEXT: PL/pgSQL function public.log_book() line 1 at SQL statement psql:/tmp/data-dt.sql:71: ERROR: must be owner of table books psql:/tmp/data-dt.sql:107: ERROR: permission denied for sequence authors_id_seq ``` Read the `must be owner of table books` line together with the one under it. The disable was refused, the `COPY` that followed ran anyway with the trigger fully armed, and back came the exact error the flag exists to prevent. `INSERT` is not enough, and nothing short of table ownership or superuser will do it. Denied `setval` calls leave their own residue on the way out. `authors` finished with three rows while `authors_id_seq` stayed at `last_value=1, is_called=f`, so the next insert without an explicit id asks for 1 and collides with a row already sitting there. Two spellings of the disable exist, and the gap between them is wider than the words suggest. Both statements below run against a `books` table whose `author_id` references `authors`, using an author id that does not exist. ```sql ALTER TABLE books DISABLE TRIGGER USER; INSERT INTO books (id, author_id, title) VALUES (99, 999, 'orphan via USER-disabled trigger'); ``` ``` ERROR: insert or update on table "books" violates foreign key constraint "books_author_id_fkey" DETAIL: Key (author_id)=(999) is not present in table "authors". ``` ```sql ALTER TABLE books DISABLE TRIGGER ALL; INSERT INTO books (id, author_id, title) VALUES (98, 998, 'orphan via ALL-disabled trigger'); ``` ``` INSERT 0 1 ``` Author 998 does not exist either. Foreign-key checks are internal system triggers, which `USER` does not touch, so the first orphan is correctly rejected. Suspend `ALL` and the second one lands. Since `ALL` is what `pg_dump --disable-triggers` emits, the flag will load rows in an order the constraints would refuse, and an inconsistent dump goes in as quietly as a consistent one because nothing checks it on the way through. ### One transaction with constraints deferred Ordering, not triggers, is what the second lever addresses, and declaring a foreign key `DEFERRABLE` accomplishes nothing on its own. Hand-shuffling a dump so the `books` block sits above the `authors` block shows why. ``` psql:/tmp/shuffled.sql:4: ERROR: insert or update on table "books" violates foreign key constraint "books_author_id_fkey" DETAIL: Key (author_id)=(1) is not present in table "authors". COPY 1 ``` `INITIALLY IMMEDIATE` still checks at the end of each statement, so `books` ends at 0 and `authors` at 1. Wrap the same shuffled file in a transaction that actually defers, and it goes through. ```sql BEGIN; SET CONSTRAINTS ALL DEFERRED; -- COPY public.books ... (out of order, on purpose) -- COPY public.authors ... COMMIT; ``` ``` BEGIN SET CONSTRAINTS COPY 2 COPY 1 COMMIT ``` Two books and one author went in backwards and came out correct. What deferring leaves entirely alone is triggers. A row count taken mid-transaction, before `authors` has received a single row and long before `COMMIT`, already finds two audit rows waiting. ``` BEGIN SET CONSTRAINTS COPY 2 audit_rows_seen_before_authors_loaded_and_before_commit ----------------------------------------------------------- 2 COPY 1 ``` An ordinary AFTER INSERT trigger fires at the end of its own statement and has no relationship with constraint timing whatsoever. Now put both problems in one file, which is what a real dump hands you, by applying the actual preamble on top of the shuffled and deferred script. ``` BEGIN SET CONSTRAINTS psql:/tmp/shuffled_realistic.sql:7: ERROR: relation "book_audit" does not exist LINE 1: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'inser... ^ QUERY: INSERT INTO book_audit(book_id, note) VALUES (NEW.id, 'insert seen') CONTEXT: PL/pgSQL function public.log_book() line 1 at SQL statement psql:/tmp/shuffled_realistic.sql:8: ERROR: current transaction is aborted, commands ignored until end of transaction block ROLLBACK ``` The last word in that transcript is `ROLLBACK` and the script said `COMMIT`, because Postgres demotes the commit of a failed transaction. Every table ends at zero, including `authors`, which had no trigger, no ordering problem, and nothing to do with any of it. Under plain autocommit the same trigger failure cost one table. Deferring constraints requires one transaction around the entire restore, and that transaction is what hands a single trigger error the authority to discard tables it never went near. The deferrable machinery itself, along with the two other ways out of a cyclic schema, gets a full walkthrough in [the circular foreign key guide](/blog/circular-foreign-key-seed). ### `session_replication_role = replica` Nothing on the pg_dump command line reaches the third lever; it is a session parameter. In the container it let an insert whose `author_id` pointed at nobody straight through and kept the audit trigger silent, all without touching a single table definition, while the unprivileged role asking for the same thing was refused outright. ``` SET session_replication_role = replica; ERROR: permission denied to set parameter "session_replication_role" ``` An explicit `GRANT SET ON PARAMETER session_replication_role` lifts that refusal, and short of the grant it takes superuser. That privilege profile keeps the lever inside restore pipelines. Nobody sets it in a development shell, and the complete inventory of what the parameter switches off, what it leaves running, and how it compares with the two other levers has [a bench of its own](/blog/session-replication-role). ## Circular foreign keys and a warning with exit code 0 Some schemas offer no valid load order at all, such as two tables that each hold a nullable foreign key to the other, with rows that genuinely cross-reference. `pg_dump` spots the cycle and says so on stderr. ``` pg_dump: warning: there are circular foreign-key constraints among these tables: pg_dump: detail: cats pg_dump: detail: dogs pg_dump: hint: You might not be able to restore the dump without using --disable-triggers or temporarily dropping the constraints. pg_dump: hint: Consider using a full dump instead of a --data-only dump to avoid this problem. ``` That run's exit code is 0, so a pipeline checking the status will never learn the warning happened, and the file itself carries no marker either, just the usual `COPY` blocks with `cats` arbitrarily ahead of `dogs` because one of them has to go first. Restored into an empty copy of the schema with constraints immediate, both orderings lose and both tables finish at zero rows, since each table's first row needs the other already populated. Re-dumping with `--disable-triggers` and restoring as the owner put both back with their cross-references intact, which is the first of the two remedies `pg_dump` printed in its own hint. ## Before you run the restore One failure in this report is worth fixing in the code rather than working around at the command line. A trigger function that writes to a bare table name breaks any restore that leaves triggers armed, deferred constraints included, and schema-qualifying the function body is a five-character edit that retires the whole class. The two levers that suspend triggers dodge the error without fixing it. The whole failure class also assumes a file has to travel between two databases, and it does not. [Seedfast](/docs/seed-your-database) builds rows directly against the schema in front of it, and nothing that arrives that way brings a primary key or a sequence position along. ## Frequently asked questions ### Why does a pg_dump data-only restore fail with relation does not exist? **The empty `search_path` in pg_dump's own preamble is doing it.** Any trigger function that names a table without its schema stops resolving for the length of the restore, and the error you get back names the table the trigger writes to rather than the one being loaded, which is why it reads like a missing object. Schema-qualify the function body, or suspend triggers for the load with `--disable-triggers`. ### How do I restore a data-only dump into a database that already has data? **Expect a primary-key collision on every table whose ids overlap, and choose the failure mode before you start.** Default psql keeps whatever managed to load and abandons the rest, which leaves a mixture belonging to neither database. Add `--single-transaction` and the first error rolls the whole file back, so the target ends where it started. ### Does pg_restore report errors better than psql? **Yes. `pg_restore` exits 1 and prints `errors ignored on restore: N`, while `psql -f` exited 0 in every run here that left `-v ON_ERROR_STOP` off.** With `-v ON_ERROR_STOP=1` psql stops burying errors and a script failing the same way exited 3 at the first failure. The custom format also lets `--disable-triggers` be chosen at restore time against an archive dumped without it, which a plain SQL file cannot offer because its `ALTER TABLE` statements are static text. ### How do I fix a sequence that is ahead of its table after a failed restore? **Point it back at the table's real maximum with `setval`, the same realignment any explicit-id load ends with.** The dump's own trailing `setval` already ran and believed the dump, not the table, so the correction has to come from you. The full set of realignment recipes, including identity columns and whole-schema sweeps, lives in [the out-of-sync sequence guide](/blog/postgres-sequence-out-of-sync). ## Related guides - [PostgreSQL test data: a syntax cookbook](/blog/test-data-postgresql). The export half of this, where `--table` and `--exclude-table-data` carve a slice out of a larger database. - [The Postgres insert that fails right after a successful load](/blog/postgres-sequence-out-of-sync). What to do when a load leaves the sequences pointing at the wrong number. - [Circular foreign key seed: three workarounds that actually run](/blog/circular-foreign-key-seed). Deferred constraints, nullable back-edges and data-modifying CTEs, each against real terminal output. - [Get started with Seedfast](/docs/seed-your-database). Filling a schema with generated rows, so no file has to move between databases at all. --- # The Postgres Insert That Fails Right After a Successful Load > Source: https://seedfa.st/blog/postgres-sequence-out-of-sync > Last updated: 2026-08-21 The load finished without complaint, with row counts matching the fixture file and every foreign key resolving, but then the application inserts a row of its own, and Postgres refuses it with the unique violation [23505](/tools/postgres-error-code-lookup#23505): ``` ERROR: duplicate key value violates unique constraint "users_pkey" DETAIL: Key (id)=(1) already exists. ``` Nothing is corrupt and nothing needs restoring. What you do have is a Postgres sequence out of sync with the table it feeds, the most common way a clean data load leaves a database broken, and the mechanism behind it is almost disappointingly plain, because writing an explicit `id` never tells the sequence that the value has been taken. Everything below was run against PostgreSQL 18.6 in a throwaway container on 2026-08-21, and the outputs are pasted as they came back. ## Key Takeaways - Explicit ids and generated ids come from two different places, and loading the former doesn't move the latter. - Moving from `serial` to an identity column changes nothing about this. `GENERATED ALWAYS` at least refuses the load outright, but add `OVERRIDING SYSTEM VALUE` to get past it and you inherit the same stale sequence. - `pg_get_serial_sequence()` resolves the sequence behind a column for both `serial` and identity, which matters because a sequence keeps its original name when the table is renamed. - On an empty table the popular `setval(seq, max(id))` recipe quietly does nothing at all, since `setval` handed a NULL returns without acting. - Whether the number you pass to `setval` is the next value or the last one used comes down to the `is_called` flag. Get it backwards and you lose exactly one id. ## Why the sequence goes out of sync A `bigserial` column is really a `bigint` carrying a default of `nextval('')`, so supplying your own value in the `INSERT` means that default is never evaluated at all, and the sequence sits where it was while the table fills up around it. ```sql CREATE TABLE users (id bigserial PRIMARY KEY, email text NOT NULL UNIQUE); INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com'), (3, 'c@example.com'); SELECT last_value, is_called FROM users_id_seq; ``` ``` last_value | is_called ------------+----------- 1 | f (1 row) ``` ![Three rows loaded with explicit ids while the Postgres sequence out of sync behind them still reports last_value 1, so the next generated id repeats 1 and collides](/blog/postgres-sequence-out-of-sync-2.svg) Three rows in the table, and the sequence still reports the state it had at creation, `last_value` of 1 with `is_called` false, which together mean that 1 has not been handed out yet. The next insert that lets Postgres pick the id therefore asks for 1, and 1 belongs to the fixture row you loaded a second ago. ```sql INSERT INTO users (email) VALUES ('d@example.com'); ``` ``` ERROR: duplicate key value violates unique constraint "users_pkey" DETAIL: Key (id)=(1) already exists. ``` The failure surfaces late, which is what makes it confusing. Your seed ran green and so did CI, and the error then waits for the first write that a human or a test actually performs. ## Identity columns do not save you Since Postgres 10 the standard-conforming spelling is an identity column, and teams that moved off `serial` sometimes assume the problem moved with it, which it didn't. ```sql CREATE TABLE t_ident (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, x text); INSERT INTO t_ident (id, x) VALUES (1, 'a'), (2, 'b'); INSERT INTO t_ident (x) VALUES ('c'); ``` ``` ERROR: duplicate key value violates unique constraint "t_ident_pkey" DETAIL: Key (id)=(1) already exists. ``` `GENERATED ALWAYS` is stricter and, for once, the strictness is useful, because it turns a silent trap into an immediate complaint with the workaround printed underneath: ``` ERROR: cannot insert a non-DEFAULT value into column "id" DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS. HINT: Use OVERRIDING SYSTEM VALUE to override. ``` Take the hint, and the load succeeds. So does the collision that follows it, because `OVERRIDING SYSTEM VALUE` only suspends the check that blocks your value from being written and has nothing at all to say about the sequence underneath, which stays parked at 1 while rows 1 and 2 go in. ## The one-line fix, and two ways it is usually written wrong Point the sequence at the largest value the table currently holds: ```sql SELECT setval(pg_get_serial_sequence('users', 'id'), (SELECT max(id) FROM users)); ``` ``` setval -------- 3 (1 row) ``` The next insert now returns id 4 and the incident is over, though two details in that line are worth more than the line itself. **Do not hardcode the sequence name!** Nearly every version of this snippet on the internet writes `users_id_seq` directly, which is correct until somebody renames the table. Sequences don't follow: ```sql ALTER TABLE users RENAME TO members; SELECT pg_get_serial_sequence('members', 'id'); ``` ``` pg_get_serial_sequence ------------------------ public.users_id_seq (1 row) ``` The table is `members`, the sequence is still `users_id_seq`, and a seed script that builds the name by string concatenation now targets a sequence that has nothing to do with the table it thinks it is fixing. `pg_get_serial_sequence()` asks the catalog instead of guessing, and it answers for identity columns too, despite the "serial" in its name. **Watch the empty table.** Over zero rows `max(id)` is NULL, and because `setval` is strict, handing it a NULL means the call comes back without touching anything: ```sql SELECT setval(pg_get_serial_sequence('empty_t', 'id'), (SELECT max(id) FROM empty_t)); ``` ``` setval -------- (1 row) ``` Postgres neither errors nor changes anything, which is harmless on a freshly created sequence and quietly wrong on one that an earlier run already advanced, the situation you're in whenever a suite truncates tables between runs. The form that survives both cases carries its own `is_called` argument: ```sql SELECT setval(pg_get_serial_sequence('empty2', 'id'), coalesce(max(id), 1), max(id) IS NOT NULL) FROM empty2; ``` Where rows exist, `max(id)` becomes the last value used and the flag is true. An empty table takes the other branch, landing the sequence on 1 with the flag false, so the very next `nextval` hands out 1 rather than 2. If you have ever wondered why a reset left you starting at 2, that flag is the reason, and the difference shows up in a two-line experiment: ```sql SELECT setval('flagcheck_id_seq', 10); -- next insert gets 11 SELECT setval('flagcheck_id_seq', 10, false); -- next insert gets 10 ``` ## Resetting an identity column on its own terms Identity columns have native syntax that never touches a sequence name: ```sql ALTER TABLE t_ident ALTER COLUMN id RESTART WITH 3; ``` The next insert returns 3. Compared with `setval` it reads better and gets checked at parse time, though it covers identity columns only, so pointing it at a `serial` column tells you so plainly: ``` ERROR: column "id" of relation "users" is not an identity column ``` Which of the two you reach for is mostly a question of how the table is declared, though the `setval` form works on both kinds, which is worth something to a script that has to fix a whole schema without branching. When the table is disposable rather than seeded, `TRUNCATE t_ident RESTART IDENTITY` empties it and rewinds the sequence to 1 in a single statement, for `serial` and identity alike. ## Realigning a whole schema after a load Fixing one table by hand is fine for an incident. After a bulk load into a schema of any size you want every affected sequence found and moved without naming any of them, and the catalog knows enough to do that on its own: ```sql DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.table_schema AS s, c.table_name AS t, c.column_name AS col, pg_get_serial_sequence(format('%I.%I', c.table_schema, c.table_name), c.column_name) AS seq FROM information_schema.columns c JOIN information_schema.tables tb ON tb.table_schema = c.table_schema AND tb.table_name = c.table_name WHERE c.table_schema = 'public' AND tb.table_type = 'BASE TABLE' LOOP IF r.seq IS NOT NULL THEN EXECUTE format('SELECT setval(%L, coalesce(max(%I), 1), max(%I) IS NOT NULL) FROM %I.%I', r.seq, r.col, r.col, r.s, r.t); RAISE NOTICE 'realigned % for %.%', r.seq, r.t, r.col; END IF; END LOOP; END $$; ``` ``` NOTICE: realigned public.t_ident_id_seq for t_ident.id NOTICE: realigned public.users_id_seq for members.id NOTICE: realigned public.t_always_id_seq for t_always.id NOTICE: realigned public.empty_t_id_seq for empty_t.id NOTICE: realigned public.empty2_id_seq for empty2.id ``` Serial columns and identity columns come out the same way, because `pg_get_serial_sequence()` treats them the same, and the second line of that output catches the rename trap in the act, since the sequence behind `members.id` is still called `users_id_seq`. Run this once at the end of a load rather than sprinkling `setval` calls through a fixture file, where they rot every time a table is added. Restoring a `pg_dump --data-only` file is the exception, since that file brings its own `setval` calls, and [the data-only restore walkthrough](/blog/pg-dump-data-only-restore) shows one of them parking a sequence well ahead of a table the restore never managed to fill. ## Or stop writing explicit ids Every fix above is repair work on a self-inflicted wound. The ids exist in the load because a fixture file wanted user 1 to be Alice for assertions to hang off, and that convenience is what puts the sequence and the table on separate tracks in the first place, so it's a fair trade right up to the moment somebody inserts a row. However, there are two ways out. Let the database assign ids and capture them with `RETURNING` or a CTE, so no literal id ever appears in the file, or just generate the data instead of writing it down. That is the route [Seedfast](/docs/seed-your-database) takes, and rows that come out of a generator carry no hardcoded keys, so there's nothing to realign afterwards. Neither helps with the dump you restored this morning - for that, the `DO` block above is what you want. ## Frequently asked questions ### Why does my Postgres insert fail with a duplicate key after importing data? **Because the import wrote explicit id values, and those never advance the sequence that supplies the column's default.** The sequence keeps handing out numbers from wherever it stopped, and the first one it offers is already sitting in the table. Realign it with `setval(pg_get_serial_sequence('', ''), (SELECT max() FROM
))` and the next insert continues past your data. ### How do I reset a Postgres sequence after loading rows? **Use `setval()` with the sequence resolved through `pg_get_serial_sequence()`, not a hand-built `
_id_seq` string.** For identity columns, `ALTER TABLE
ALTER COLUMN RESTART WITH ` does the same job in standard syntax. To move a whole schema at once, loop over `information_schema.columns` and let the catalog name the sequences. ### Does TRUNCATE reset the sequence? **Only with `RESTART IDENTITY`.** A plain `TRUNCATE users` removes every row and leaves the sequence exactly where it was, so the next insert carries on from the old high-water mark. `TRUNCATE users RESTART IDENTITY` rewinds the sequence to its start value, which is what you usually want between test runs. ### Is pg_get_serial_sequence valid for identity columns? **Yes, despite the name.** It returns the backing sequence for `serial`, `bigserial`, and both flavours of `GENERATED AS IDENTITY`, and it returns NULL for a column that has no sequence behind it, which is what makes it safe to call across every column in a schema. ## Related guides - [Writing a Postgres seed script that survives the next migration](/blog/postgres-seed-script). Where the sequence reset belongs inside a maintained `seed.sql`, alongside idempotency and insert ordering. - [PostgreSQL test data: a syntax cookbook](/blog/test-data-postgresql). The `generate_series`, bulk copy and `pg_dump` mechanics behind the load that gets you into this state. - [Circular foreign key seed: three workarounds that actually run](/blog/circular-foreign-key-seed). The other constraint problem that only shows up at insert time. - [Get started with Seedfast](/docs/seed-your-database). Generate connected rows against your own schema instead of maintaining a fixture file. --- # Synthetic Data vs Real Data? First Say What It's For > Source: https://seedfa.st/blog/synthetic-data-vs-real-data > Last updated: 2026-07-24 You open the tool, and it wants a decision before you have said what the data is for. Generate the rows, or pull from what is real? Synthetic data vs real data gets framed as a head-to-head with one durable winner, so people go hunting for whichever source is generally safer or more faithful. No such winner exists. The comparison resolves only once you name the job the data is being hired for, because the property that makes a dataset excellent for one job barely registers for another. Most of the muddle comes from treating real as the default and synthetic as the fallback. Production data carries real authority, since it actually happened, but it earns its keep on one class of work and means little on another. Sort the work into two lanes, and the stalemate breaks. ## Synthetic data vs real data starts with the job Almost everything anyone generates or copies a database for lands in one of two lanes, and the lanes want opposite things. The first is statistical work, the training and evaluation of machine-learning models and the analytics beside them. Here the data stands in for a population, so what matters is that its overall shape matches reality closely enough. Correlations between columns hold, the numbers carry real skew, and a one-in-a-thousand case turns up about once in a thousand rows, and a few wrong rows in a set of millions do no damage, because the model reads the mass and not the exception. The second lane is the application database, the one your software connects to while you build against it, test it, stage it, or demo it — a lane that answers to a schema rather than to a distribution. Every foreign key must land on a row that exists; miss a constraint or leave a required column empty, and the database refuses the write and the work halts. So, a real-world distribution becomes a bonus here; validity against the schema is what the lane demands. ![Decision router for synthetic vs real data — statistical work is judged on distributional fidelity, application databases on relational correctness, and the winning source differs by lane](/blog/synthetic-data-vs-real-data-2.svg) ## The dividing line is error tolerance Underneath the two lanes sits the reason they diverge - a training corpus and an application database forgive mistakes at wildly different rates. Give a model a batch in which a small percentage of rows are wrong, and it still learns the pattern that dominates; the bad rows blur into noise the training averages over, and if the data improves later, the model improves with it. However, an application database grants no such margin, and a single row whose foreign key points at a customer that was never inserted is not an error to be averaged away - the insert is refused, the load stops there, and every test that expected that row fails for a reason unrelated to the code under test. Moreover, a statistical workload can name a defect rate it tolerates and run below it and a relational one has no rate to name. ## In the statistical lane, real data usually wins For work that feeds a model or a dashboard, real data holds a genuine edge, because you are chasing fidelity and real data is fidelity by definition. It carries the correlations nobody thought to encode and the messy tail nobody would have invented. When you can get it, and are allowed to use it, real data is often the right call for lane one. The catch lives in those two conditions: production data comes wrapped in access controls for good reason, and pointing a training pipeline at raw customer records is the exposure those controls exist to prevent, and it ages, since last quarter's snapshot describes a business that has moved on. Yes, it hands you only the cases that occurred, so a model needing thousands of examples of a rare fraud pattern gets the handful real data has and no more. Synthetic data is built for exactly that opening, producing as many rare cases as a model needs and carrying no real record. So, its weakness here is fidelity itself, the hard problem the fidelity-first synthesis platforms and open-source synthesizers spend their engineering on. The honest read on synthetic data vs production data in lane one is that synthetic wins when access is blocked or rare cases are scarce, and loses when its fidelity falls short of the real distribution. ## The application lane flips the answer Populate a database your application runs against and the calculus inverts. Fidelity stops being the point; correctness against the schema becomes the whole of it. You need a dataset where every reference resolves and every constraint holds, so the app boots and the tests run instead of dying on a bad insert. Real production data can clear that bar, but dragging a copy into a development or staging environment looks safe and is not. It brings the exposure problem back where the controls are weakest, a full clone is heavy enough to slow every reset, and it drifts from the schema the moment someone ships a migration the dump predates. It also cannot manufacture the case you need on demand, since production has the customers it has, not the five hundred with a canceled subscription and refunded order your edge-case test wants. Synthetic data is the natural fit here, with one caveat that trips teams up. A generator filling each column from its own source of randomness hands you a users table and an orders table that look perfect alone and do not connect, order rows pointing at customer IDs that were never created, which a real foreign key rejects on the first insert. Why believable values are the easy half while making the rows connect is the hard half is the subject of [how synthetic generation actually works end to end](/blog/synthetic-test-data-generation); the deeper version, where a reference resolves and still means nonsense, runs through [what coherent data really demands](/blog/realistic-test-data). Some teams split the difference by masking a production copy, which trades one set of headaches for another and sits beside the other options in the [seeding-tool landscape](/blog/data-seeding-tools). Real data, for all its authenticity, is out of place in this lane, and naive synthetic data fits the lane while still breaking the schema. Seedfast reads the live schema before it writes anything, which is why every `orders` row it produces points at a customer created in the same run. ## The comparison, by job Laid side by side, the two lanes ask nearly opposite things of the same two sources. | Question | Statistical lane (models, analytics) | Application lane (dev, test, staging, demos) | | ------------------------------- | ------------------------------------------------------- | --------------------------------------------------- | | What the data stands in for | a population's distribution | a schema the software runs against | | The bar for "good" | distributional fidelity | relational correctness | | Cost of one wrong row | absorbed into a defect rate | halts the insert and every test after it | | Real data's main liability | access limits, staleness, no rare cases on demand | exposure risk, size, drift, no edge cases on demand | | Synthetic data's main liability | fidelity gaps against the real distribution | naive generators that ignore the schema | | Usually the better source | real, or high-fidelity synthetic when access is blocked | synthetic that reads the schema | Read it down a column for your job, not across a row for a winner. If the data feeds a model or a report, start from real and turn to synthetic when access rules or missing rare cases force it, then judge what you generate on how faithfully it reproduces the distribution. If it populates an application's database, start from synthetic and hold it to one standard above the rest, that every row satisfies the schema. The synthetic vs real test data decision comes down to exactly that. Doing it well is its own discipline, the one [database seeding](/blog/database-seeding) covers, and the AI-driven options are ranked in [the AI generator comparison](/blog/best-ai-test-data-generator). ## Where Seedfast fits, and where it doesn't Seedfast works one lane. It is data infrastructure for the application database, the development, testing, staging, and demo environments where relational correctness is the bar and a copy of production is a liability. It connects to a live PostgreSQL database, reads the current schema, and generates connected, referentially consistent rows that fit it, so a child row lands on a parent that exists rather than on an ID that was never created. Describe what you want in plain language and it fills the database in one command: ```bash seedfast seed --scope "500 customers with orders, payments, and a realistic spread of account ages" ``` Because those rows are invented from the schema and never sampled from production, no production access is required and no production rows are involved, which is why a generated dev or staging database sidesteps the copy-of-production liability above. That is a consequence of generating rather than copying, not a compliance claim. Seedfast is not built for lane one; it does not train your models or feed an analytics warehouse a replica of production statistics, work that wants a different tool and a different definition of good. ## Frequently asked questions ### Is synthetic data as good as real data? It depends entirely on the job. On statistical work, where fidelity to a real distribution is what counts, carefully sampled real data is the benchmark and synthetic is judged by how near it gets. On an application database, where all that matters is that every row satisfies the schema, a copy of production is more hazard than help and well-formed synthetic data wins outright. ### Does synthetic data work for training machine-learning models? Yes, and that is squarely lane one. A model learns the pattern that dominates its training set, so it tolerates a defect rate that would wreck an application database, which is why generated data can train a working model even when it is imperfect. Two things decide how well it works. One is how faithfully the synthetic set reproduces the real distribution, rare cases included. The other is whether you validated the result on held-out real data rather than on more synthetic data. ### Can I just use a copy of production for my test database? You can, and plenty of teams do, but it hides its costs. A production copy carries real customer data into environments with weaker controls, weighs enough to slow every reset, and goes stale the day someone ships a migration. Worse for testing, it holds only the cases that already happened, so the edge case your feature needs may not be there at all. The synthetic data vs production data trade-off usually favors generating the rows you need over importing the ones you have. Seedfast generates them against the schema your database has at that moment, which is the one thing a copy taken last month cannot do. ### When is real data the right choice? Whenever the job is statistical and you are cleared to use it. Model training, evaluation, and analytics all read the aggregate and absorb a few odd rows, so they benefit from the fidelity only real data carries for free. The two gates are permission and freshness; fail either and high-fidelity synthetic data steps in. ## Match the source to the job The reason "synthetic data vs real data" stays unsettled in the abstract is that it was never one question. It is at least two, wearing the same words. Sort your work into the statistical lane or the application lane, check which property decides quality there, and the choice that felt like a coin toss turns fairly obvious. For the application lane, where a single dangling reference takes down the whole run, Seedfast reads your live schema and generates connected data without touching production. [Run your first seed](/docs/seed-your-database) in a couple of minutes, or read the [pricing](/pricing) first, a free plan that asks for no card and then flat plans at $16 and $69 a month. --- # Your Agent Doesn't Need Labels. It Needs a World to Act On. > Source: https://seedfa.st/blog/training-data-for-ai-agents > Last updated: 2026-07-24 Picture a team standing up an evaluation harness for a support agent — the kind that reads an inbox, opens the CRM, and works a queue of tickets. On the first task, the agent is told to merge two duplicate records for one company and confirm the merge by email, and it fails on the opening query. There are no companies in the database, no contacts, no tickets, and every task in the suite bottoms out the same way, on a read that returns nothing to act on. "Training data for AI agents" is the phrase that search usually begins with, and it turns up labeled corpora, annotation vendors, and datasets of human-graded examples — none of which stands up the thing the agent actually needs, a system with state in it. What is missing isn't a pile of examples to learn from. It is a populated database the agent can read, change, and be graded against. ## What "training data for AI agents" actually points at For a model that classifies text or predicts the next token, training data is a heap of examples, and cleaner, larger heaps are most of the game. Agents that operate software are a different animal. When something files a ticket or runs a database migration, the score depends on what happens to a system after it acts, so the thing it trains and evaluates against is an environment made of the application, the database underneath it, the APIs it can call, and a set of tasks whose correct answers are known ahead of time. AI agent training data, in the sense that matters for these systems, is that environment loaded with realistic state, and a spreadsheet of inputs and labels does not substitute for it. This is why the search results feel off-topic. The benchmarks that research teams actually cite for software-operating agents ship as environments you run, each one carrying a starting state and a grader that inspects the end state after the agent is done. A labeled dataset can tell you whether the agent picked the right intent, but whether it left the database in a correct condition is a question only a running system can answer. ![Diagram of an agent evaluation environment — the agent acts through the application and its APIs into a database that holds the run's state, while a grader diffs the database before and after to score the run](/blog/training-data-for-ai-agents-2.svg) ## The database is the part that carries the state Strip an agent environment down to its load-bearing piece and you are left holding the database. Tasks read from it (a query for the oldest unresolved ticket, a scan for every invoice past due) and tasks write back to it, which means a run's success is literally the difference between two database states. The app is a set of doors into that state, and the APIs are handles on it. Take the rows away and none of the doors lead anywhere. The agent told to escalate a stuck order has no order to escalate, and the grader meant to check whether the right row changed has no row to check. Because every task in a well-built suite is defined as a transformation of state, with a starting condition the environment guarantees and an ending condition the grader verifies, the trustworthiness of the whole evaluation rests on whether that starting condition was set up with rows that relate to each other the way production rows would. An empty CRM can't teach an agent to work a CRM, and it can't test one either. That's the same wall a coding agent hits when it builds a schema and never fills it, then reports every check green because empty tables have nothing to contradict, the failure mode [an agent that greenlights its own empty tables](/blog/ai-agent-empty-database) walks through in full. For an evaluation harness the stakes flip around, and you want the tables full precisely so the grader has something real to measure against. ![A support-workspace ticket queue running on seeded state — 40 companies, 300 tickets with mixed open and closed statuses, and a needs-attention rail whose oldest item matches the KPI card](/blog/training-data-for-ai-agents-4.png) ## Labeled-dataset thinking doesn't transfer The instinct carried over from model training is to treat data quality as a dial you can leave a little low. A training corpus tolerates a defect rate — mislabel two percent of a sentiment dataset and the model still learns, because the noise averages out and the gradient finds the signal underneath it. Nothing about an agent's database works that way. A schema is a set of hard constraints rather than a distribution to approximate, so a single foreign key pointing at a row that was never created isn't a small error waiting to wash out across thousands of examples — it's a broken insert that halts the load, or a row that violates [referential integrity](/blog/referential-integrity) and quietly poisons every task that later reads it. So "label more examples" is the wrong reflex when an agent has nothing to work against. The gap here is not annotation. It's a schema's worth of connected rows (companies that own contacts, those contacts owning tickets that each carry a history) sitting in the database before the agent's opening move. Hand that job to a model and ask it to write the rows itself, and [it will happily reference records it never inserted](/blog/generate-test-data-with-ai), because a language model emits plausible SQL rather than verified SQL. Seedfast writes those rows from the schema instead, so every reference in them resolves to a row the same run created. ## Repeatable state is what makes eval runs comparable Evaluation piles a second demand on the data, which is that every run has to begin from the same place. An agent evaluation environment earns trust only when its starting state is fixed and realistic; shuffle the rows between runs and the scoreboard stops meaning anything, because a higher score might signal a smarter agent or just an easier draw and you can't tell which. A seeded initial state settles that. Load the same known rows before every attempt and each run faces an identical world, so the only variable moving between runs is the agent itself. That fixed starting state is also where the grader's ground truth lives. You seeded three duplicate companies, which makes "the agent should end with two" a fact you defined rather than a guess you're checking after the fact. The unglamorous name for standing this up on a real schema is [database seeding](/blog/database-seeding) — the same operation a demo or a staging environment needs, pointed at an agent's world instead of a human's. ## Where Seedfast fits Seedfast is built for exactly this floor, the populated and connected database an agent needs before it can be trained against a task or graded on one. It connects to your application's database, reads the live schema on that connection, and writes fresh rows that come out connected and valid, every child row resolving to a parent that exists. Nothing it inserts is copied from anywhere. You describe the world you want in plain language and it builds it. ```bash seedfast seed --scope "a support workspace with 40 companies, 300 contacts, and open and closed tickets across 90 days" ``` One command stands up an entire environment's worth of state, sized to the eval rather than to production. ![Terminal output of the real seed run behind this article — seven tables planned and completed with connected rows, 2,006 rows in about four minutes](/blog/training-data-for-ai-agents-3.png) A schema is the shape of your tables and not the rows inside them, so the connection exposes table and column names while your real customer data stays out of the picture, which is what lets a regulated team seed a believable world without moving anything sensitive into it. The detail that matters for an agent pipeline is that Seedfast doesn't have to be a step a person runs by hand. It drops [inside a CI pipeline](/blog/synthetic-data-ci-cd) as a single command, and it runs as a Model Context Protocol (MCP) server, so an agent assembling its own harness can call the seed as its first move and provision the world it's about to be tested in. The environment stops being something a human prepares in advance and becomes something the pipeline stands up on demand. ## Frequently asked questions ### What is training data for AI agents? For agents that operate software rather than only generate text, training data is less a labeled dataset than a working environment made of the application, the database that holds its state, the APIs it calls, and the tasks whose correct answers are known in advance. The agent learns and is measured by what it does to that environment, so the practical bottleneck is usually standing up realistic, connected state in the database rather than collecting more labeled examples. ### Is training data for AI agents the same as a labeled dataset? Not for agents that act on systems. A labeled dataset fits supervised model training, where each example carries a target output. An agent working a queue of tickets is graded on the end state of a database after it acts, and a static list of input-output pairs can't represent that. The data that matters is the seeded state of the environment it operates in. ### Why does an AI agent need a populated database to train against? Because its tasks read and change database state. An agent told to merge duplicate records, escalate a stalled order, or reconcile an invoice has nothing to do when those rows don't exist, and no way to be graded, since the grader compares the database before and after. Empty tables leave both the agent and the evaluator with nothing to work on. ### How is application-database data different from a model training corpus? A training corpus tolerates some noise, since a fraction of mislabeled examples barely dents a trained model. An application database offers no equivalent slack — one foreign key aimed at a missing row breaks the insert or corrupts every downstream read, because a database enforces constraints instead of averaging over a distribution. Environment data has to be right on the very first row, since there's no average for a bad one to hide inside. ### How do you generate the state for an agent evaluation environment? By seeding the database from its live schema. Seedfast reads the tables and their relationships over the connection, then writes connected rows that satisfy the constraints, so the environment starts realistic and internally consistent. One CLI command does it, or one MCP call, which is what lets an agent pipeline provision the world it's about to be evaluated in. ## Stand up the world before you grade the agent The agent is the interesting part, but nothing can train or grade it until something stands up the world it works in, and that world's floor is a database full of connected, believable rows. No labeled dataset supplies that floor. [Seed your first environment](/docs/seed-your-database) against your own schema in a couple of minutes, and when it matches what your agent pipeline needs, the [free plan](/pricing) runs without a card before the flat monthly plans take over. --- # Green Tests, Empty Tables: the Step Your AI Agent Skips > Source: https://seedfa.st/blog/ai-agent-empty-database > Last updated: 2026-08-19 Hand a coding agent a one-line brief and it will surprise you. Last month I watched one write the migrations, wire the routes on top of them, and get `pnpm dev` booting clean with a green test suite on the first pass. Then I clicked into the running app. Every page rendered, and there was nothing on any of them — the dashboard read zeros across the board, and the "recent activity" feed was a blank rectangle where the demo was supposed to sit. ![An agent-built admin dashboard that renders cleanly and reads zero everywhere — no users, no orders, no revenue, and an empty activity feed](/blog/ai-agent-empty-database-1.webp) This is the AI agent empty database problem, and it's quieter than the failures that make headlines. The schema underneath was genuinely good, with real foreign keys, a couple of unique constraints, and a `CHECK` or two holding the shape together. The agent had done the structural work and stopped one step short of the thing that makes an app testable at all, which is getting real, connected rows into those tables. Seedfast covers that last step, reading the schema the agent just built and writing fresh values into every column, so what the app renders afterwards is drawn from rows that hang together across tables. Nothing it inserts comes out of production, which is what makes pointing it at a throwaway branch a decision nobody has to think about twice. For a while this was a corner case you'd hit once and shrug off, and now it has quietly become the default. Supabase reported on June 4, 2026 that more than half of all new Supabase databases are now deployed by AI agents, with Claude Code the largest single contributor since the start of the year. Those agents stand up the database, apply the migrations, and increasingly write the whole application on top — and the one step they are worst at is the unglamorous one underneath, where the tables fill with data that behaves the way real data does. ## Why the AI agent empty database problem bites Ask the agent to fix it — "insert some sample rows" — and it will. It fills the tables one at a time and, more often than not, hands you rows that are type-correct and referentially broken — a `user_id` that's a real integer in a believable range while pointing at a customer that was never generated. One query catches it: ```sql SELECT count(*) FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL; -- orders pointing at a user that doesn't exist ``` Anything above zero and you're holding noise dressed up as data. The broken insert at least fails loudly, but the case worth worrying about is the quiet one, where nothing turns red. A coding agent judges its own work by the signals in front of it — a green suite, a clean boot, pages coming back 200 — and an empty database keeps every one of them happy while hiding the behavior they were supposed to verify. Point a test at three seeded rows, or none, and pagination has no second page to break on, the N+1 query that would crawl in production returns in a millisecond against five rows, and the join that should fan out returns a tidy nothing. ![Three green signals — a passing suite, a clean boot, pages returning 200 — verified against a database that holds zero rows in every table](/blog/ai-agent-empty-database-3.svg) Here is the part that makes this an agent problem rather than a general testing footnote. A person who wrote the feature carries a mental model of what the table ought to hold, so an empty result reads as suspicious to them. With no such prior, the agent can't distinguish an empty-state bug — a view that only looks correct because there was nothing to display — from a genuine pass, so it records the empty result as success and moves to the next task. Run that unattended across a few hundred steps and you get a build that is green at every checkpoint and wrong wherever real data would have disagreed. Not everything that slips through is a performance story; authorization is the sharp example. A query meant to scope results to the current user only gets tested once a second user's rows sit in the same table, so seeding a single user leaves an endpoint that would happily return someone else's record looking completely correct — there's no other record around for it to leak. Tenant isolation and permission checks stay hidden the same way, because the data that would trip them was never there. The wider catalog of bugs that only surface at real row counts is its own subject, and [small data tells big lies](/blog/small-data-big-lies) walks through six of them. ## Four ways to fill the tables Once you decide the data deserves a real step rather than whatever the agent improvises at 2 a.m., four approaches show up. | Approach | What it's good at | Where it leaves the agent stuck | | --- | --- | --- | | Hand-written seed script | Total control over a handful of rows you care about | You own the insert order and every foreign key by hand, and it drifts the day the schema changes | | Faker-style random fillers | A column of believable names, emails, or dates in seconds | Each value is drawn blind to the column beside it and the table across the foreign key, so the relationships stay yours to wire up | | ORM seeders (Prisma, Drizzle, TypeORM) | Lives in your codebase, typed against your models | Still a script someone writes and keeps current — the ORM gives you a place to put the logic, not the data | | Seedfast (schema-aware generation) | Reads the live schema and writes connected rows across every table | Wants a live connection string, so it isn't a throwaway file you commit | The first three share a ceiling. Each leaves the relationships — the part that makes a dataset behave like a graph instead of a pile of independent columns — for you or the agent to reconstruct, which is precisely the fiddly, repetitive work you don't want a probabilistic agent guessing at against a schema it only half-remembers. Faker and its ports are genuinely the right call for a single column or a quick throwaway script, and if the ORM lane is where you live, the [database seeder options get compared head to head](/blog/database-seeder). None of the three reads the schema and keeps the references intact on its own. That last row is the one built for the empty-tables problem the agent walked away from. Seedfast reads the live schema and writes rows that stay connected, every child row resolving to a parent that exists, which is the [referential integrity](/blog/referential-integrity) part Faker structurally skips. It's also [where raw model-written inserts come apart](/blog/generate-test-data-with-ai), because a language model asked to emit SQL will cheerfully reference a row it never created. ## Where Seedfast fits This is the seam Seedfast is built for. It connects to your application's database, reads the live schema on that connection, and writes fresh rows that come out connected and valid — every child row pointing at a parent that exists, including tables that reference themselves or loop back to each other. Nothing it inserts is copied from anywhere. A schema is the shape of your tables rather than the rows inside them, so a regulated team is reviewing table and column names here, and the customer data never enters the picture. You describe what you want in plain language and it fills the tables: ```bash seedfast seed --scope "200 users, each with orders, payments, and 3 months of activity" ``` ![The same admin dashboard after one Seedfast run, showing 200 users, 600 orders, and a three-month revenue chart drawn from connected rows](/blog/ai-agent-empty-database-2.webp) That's the dashboard from the top of this post, one run later. The part that matters for an agent workflow is that you don't have to be the one to run it. Seedfast also runs as an [MCP server](/docs/mcp-setup-guide), so the seed becomes the agent's own opening move — a single `seedfast_run` call before it starts whatever needs data underneath it — the same pattern the [Claude Code walkthrough](/blog/claude-code-mcp-database-seeding) and the [Codex CLI guide](/blog/codex-cli-database-seeding) set up from scratch. Point that agent at a disposable database rather than the real one, [a decision worth making on purpose](/blog/ai-agent-production-database), and seed the throwaway. One run scopes down to a single table or out to the several hundred a mature schema carries. ## When an empty database is actually fine Not every empty table is a problem worth stopping to solve. Three commits into a prototype with the schema changing by the hour, seeding is wasted motion — the agent's five throwaway rows are plenty to confirm a page renders and a route responds, and you're better off moving on. Stability is what changes the math. Once the schema settles and someone wants a [realistic demo](/blog/demo-data-generator), or the first test starts to lean on more than one row existing, that empty database stops being harmless and becomes the first thing a reviewer or a customer runs into. Picking the tables up deliberately, before a demo or a test quietly starts depending on them, is the half of the job worth owning yourself. ## Frequently asked questions ### What is the AI agent empty database problem? It's what happens when a coding agent builds the schema and the application but never fills the tables with usable data, then reports success anyway. The test suite passes because empty tables give its assertions nothing to contradict. Boot it and the demo even looks done, right up until someone clicks in and finds zeros everywhere. That structural work is real; the data step underneath it got skipped, and the agent has no way to notice it mattered. ### Why do tests pass against an empty database? Because a green test only means the code didn't contradict the data it was given, and an empty table can't contradict anything. Picture a paginated list with a page size of twenty. Three rows in the table leaves no second page for the "next" control to advance into, so the code path that handles pagination simply never runs, and the test written to cover it passes by never being exercised. N+1 queries and authorization checks go quiet the same way, invisible until the row count that would trip them actually exists. That's the trap in empty database testing — the suite runs to completion and certifies almost nothing, which reads identically to a suite that actually exercised the feature. ### Can't the AI agent just seed the database itself? It can insert rows, but a coding agent left to write inserts by hand tends to produce values that satisfy each column and break the relationships between tables — a foreign key aimed at a parent it forgot to create. The more reliable pattern is to hand the agent a tool that reads the schema and generates connected data in one call. Because Seedfast runs as an MCP server, the agent triggers that seed itself over the Model Context Protocol instead of improvising SQL it can't verify. ### Is an empty database ever fine for an agent to work against? Early on, yes. While the schema is still moving every hour, a handful of throwaway rows is enough to confirm a page renders, and investing in real data is premature. The moment worth watching for is when the schema settles and a test or a demo starts depending on the data being there, because that's when a coding agent empty database quietly turns from a non-issue into the bug a customer finds first. One Seedfast run against the settled schema is what closes it, and the run after the next migration picks up whatever that migration added. ## Own the step the agent left blank The agent is good at the structural half — it writes the schema and wires the routes, and the suite comes up green — then hands the whole thing over with the tables still empty and no sense that anything's missing. Filling those tables with connected, realistic data is the step that decides whether the green means anything. [Seedfast](https://seedfa.st/) reads your live schema and writes that data in a single run, as a CLI step or as an MCP call the agent makes on its own, and the [free plan](/pricing) doesn't ask for a card. --- # Demo Data Looks Real Until Someone Clicks Into It > Source: https://seedfa.st/blog/demo-data-generator > Last updated: 2026-09-18 The account looks lived-in — forty companies down the sidebar, avatars scattered across a team view, invoices going back six months, the kind of screen you'd happily drop into a pitch deck. Then the prospect clicks into one of those invoices on the call, and the customer it's billed to isn't anywhere in the customer list. Upstream, a demo data generator had filled every table with believable rows and never once checked whether a row in one table points at a row that exists in another. That specific failure is what this page is about. Filling a database so it reads as real to a human is a different problem from producing a flat file of plausible values, and most tools that surface when you search for a demo data generator solve the second one cleanly while leaving the first untouched. They're genuinely good at the flat file, and they run out of road the moment your demo spans tables that reference each other, which happens on the first click for anything shaped like a real product. Seedfast works from the other end, reading the live schema of the database you're demoing and writing rows into it that already point at each other correctly. ## Key Takeaways - Demo data and test data are different jobs. Demo data has to look coherent to a person watching a screen; test data has to trip the edge cases a CI check reads, and no one will ever look at it. - The flat generators that rank for this term — Mockaroo, generatedata.com, Beeceptor — fill one table at a time, so foreign keys between tables don't line up. That's fine for a single CSV or a mock API, and a real problem once a demo spans related tables. - A convincing demo has shape: a few large accounts over a long tail of small ones, signup dates that precede activity, invoice totals that reconcile with their line items. - "Demo data for a SaaS" is not the same as a sales-demo-automation platform such as Reprise or Demostack. Those personalize the sales pitch itself and are typically a much larger purchase than a data generator. - [Seedfast](https://seedfa.st/) reads your application's live schema and writes a connected demo dataset across every related table in one run, so an invoice on screen resolves to a customer who exists. ## What "demo data" actually means Demo data is data you load into your own product's database so the thing looks real to whoever is watching it. A prospect clicking through a trial account, an investor sitting through a walkthrough, a new user poking at a sandbox on day one — each of them is judging the product partly on whether its data feels like a real company has been using it. Test data answers a narrower question. It exists to drive assertions, so it cares about the null field, the boundary value, and the one row that makes a CI check go red, and nobody ever admires how it looks. The two overlap enough that people reach for the same tools for both, then get surprised when a generator tuned for one disappoints at the other. A dataset built to break a pagination bug rarely reads like a tidy sales demo, and one assembled to charm a prospect won't go hunting for the ugly corner your test suite lives for. One disambiguation is worth making before we go further, because this term pulls two very different searchers. If what you want is software that assembles and personalizes the *sales pitch itself* — swapping in a prospect's logo, staging a guided product tour, tracking who watched what — that is a sales-demo-automation platform like Reprise, Demostack, or Saleo, and it's a much larger purchase aimed at revenue teams. This page is about the other job of getting realistic rows into your own application's database. If you landed here looking for the pitch-automation category, that's a different shelf entirely. ## Why flat row generators break the moment tables relate Give the incumbents their due first, because they earn it. Mockaroo will hand you a realistic CSV, JSON, or SQL file in under a minute with no install, and it doubles as a mock-API host (per mockaroo.com, as of July 2026). generatedata.com is open-source under the GPL and free to self-host, with roughly thirty data types and a stack of export formats (per the project's GitHub, benkeen/generatedata, as of July 2026). Beeceptor advertises 300-plus field generators and can turn what it produces into a live mock REST API on the spot (per beeceptor.com, as of July 2026). SingleStore ships a basic column-by-column dummy-data widget too (as of July 2026). For a quick single-table export or one faked API response, reaching past any of these would be overkill. The shared limit is structural, and it isn't a bug in any of them. Each generates one table at a time, with the columns of that table drawn independently. There is no live database in the loop, so nothing connects the value in one table to a value in another. Watch where that lands on a product. A SaaS demo usually needs `organizations` that own `users`, `users` who hold `subscriptions`, `subscriptions` that produce `invoices`, and `invoices` backed by `usage_events`. Generate those five tables in five independent passes and the foreign keys are decorative. An `invoice.customer_id` is a believable integer that happens to point at no customer you created. Every account ends up on the same subscription tier because nothing wired tier to account. Drill into a single record on screen and the illusion falls apart, which is precisely the interaction a demo is built to invite. The [Mockaroo alternative](/compare/mockaroo-alternative) write-up walks through this exact wall in more depth, and the [test data generation](/blog/test-data-generation) rundown covers the same tools from the CI-suite angle. ![psql output on flat-generated tables, where invoices joined to customers come back empty-handed and a LEFT JOIN count finds 174 orphaned invoices](/blog/demo-data-generator-1.webp) psql makes it countable — in the sample above, eight of the ten invoices bill customers that don't exist, and the orphan count across the whole table comes to 174. ## What a demo actually needs to look real Valid foreign keys are only the floor, and a demo that clears referential integrity can still look obviously synthetic the second someone with domain sense looks at it, because real data carries a *shape* that random values don't. Picture the customer list you'd actually want on screen. It would hold a handful of large accounts with dozens of seats and long histories sitting above a long tail of small accounts that signed up last week and barely logged in — not fifty identical companies each with the same three users. Signup dates would land before the first invoice rather than after it, and invoice totals would add up from their line items instead of floating free. None of that is exotic; it's the difference between rows generated with awareness of each other and rows that weren't. This is the same coherence argument that [realistic test data](/blog/realistic-test-data) makes for a test suite, turned here to face the person watching. A test only fails when incoherence trips a check it happens to run. The demo has a harsher reviewer in the human watching it, who spots the seams fast, because a customer that predates its own signup or an order with no buyer is exactly the thing a skeptical prospect clicks straight toward. ## Generating demo data against your own schema The way out is to stop generating tables in isolation and start generating from the schema that already defines how they relate. [Seedfast](https://seedfa.st/) runs on that principle. It reads your staging or demo database's live schema on every run, every table and column and type and foreign key in it, then writes a connected dataset across all of them in one pass. Rows come out referencing each other correctly, so an invoice lands on a customer who exists and a subscription belongs to an account that was created before it. Nothing is left as a flat file to reconcile by hand afterward. You describe the demo you want in plain language and Seedfast fills the tables: ```bash seedfast seed --scope "50 organizations with users, subscriptions, and 6 months of invoices" ``` ![Seedfast CLI run seeding organizations, users, subscriptions, and invoices in one pass — 575 connected rows across four tables](/blog/demo-data-generator-2.webp) There's no form to fill column by column and no per-table export to stitch back together afterward. Because it reads the schema fresh each time, the migration you ship next week gets picked up on the next run, with no saved definition to quietly break. It runs the same way as a CLI step against a staging connection string or as an [MCP server](/docs/mcp-setup-guide) inside an AI coding agent, so the agent building the demo can populate the database itself. There's a [free plan](/pricing) that takes no card if you want to point it at a real schema before deciding anything. ## Demo data generator options compared The flat generators win the rows they were built to win, a single fast export or a hosted mock API, while Seedfast takes over once those rows have to agree with each other. | Capability | Mockaroo | generatedata.com | Beeceptor | Seedfast | | --- | --- | --- | --- | --- | | Reads your live database schema | No | No | No | Yes — on every run | | Keeps foreign keys valid across tables | Manual — own web form only | Not documented | No | Yes — every child row points at a real parent | | Multi-table demo in one run | No — one table at a time | No — one table at a time | No — one dataset at a time | Yes — all connected tables together | | Runs without a live database | Yes — exports files | Yes | Yes | No — needs a connection string | | Output | CSV, JSON, SQL, Excel | CSV, SQL, JSON, XML, and more | JSON, CSV, SQL, plus a live mock API | Rows written straight into your database | | Price | Free ≤1,000 rows/file; paid $60–$7,500/yr | Free, open-source (GPL-3.0) | Free tier; no pricing on the tool page | Free plan, then flat monthly credits, no per-row metering | | Best for | Fastest single flat file or a mock API | Free self-hosted flat exports | Turning generated rows into a mock API | Coherent multi-table demos against your own DB | *Competitor facts verified as of July 2026: Mockaroo pricing per mockaroo.com/pricing; generatedata.com license and feature counts per the benkeen/generatedata GitHub repository; Beeceptor capabilities per beeceptor.com. In my read, all three are the right call when you need one flat table fast, though they become the wrong tool the moment a demo depends on tables referencing each other, which no amount of extra rows fixes.* The quickest check is a run against your own staging schema — that's what the [free plan](/pricing) is for. If you're weighing Mockaroo specifically, the [Mockaroo alternative](/compare/mockaroo-alternative) page has the full side-by-side. ## Where this fits: trial accounts, staging, investor decks, and QA walkthroughs The "make it look alive" problem shows up in more places than a sales call. A self-serve trial that greets a new user with an empty dashboard converts worse than one opening on a populated workspace. Investors want enough history behind the charts for them to read as a story, and a QA walkthrough of a new feature lands better against data that resembles a real tenant than against three rows named "Test User". What all of these share is the need for a database that looks used, without a real customer's information anywhere in it. That last constraint is why a demo environment and a [staging environment](/blog/staging-without-prod-data) tend to converge. Both have to look real and neither should be a copy of production. Generating the data from the schema satisfies both at once. Nobody's personal data leaves production, because none of it was ever involved; what the generator does see is schema structure — table and column names — which a regulated team will want to review. And there's no anonymization script to keep in sync as the schema moves. ## Frequently asked questions ### What is demo data? Demo data is fabricated data you load into your own application's database so the product looks real to whoever is watching the screen. The bar is visual coherence: names that read like a genuine customer base, dates in a sensible order, and records that connect when someone drills into them — down to the invoice that resolves to a real customer. ### What's the difference between demo data and test data? Test data exists to exercise your code — edge cases, null fields, boundary values, the exact rows an assertion checks — and no one ever looks at it. Demo data exists to be looked at, so it has to hold together as a believable picture even though no test will read it. One generator can serve both, but they pull in different directions, and a dataset tuned to break a pagination bug rarely resembles a clean sales demo. If you actually wanted software that assembles the pitch itself, that's a sales-demo-automation platform like Reprise or Demostack — a separate, pricier category from a data generator. ### Can a demo data generator handle foreign keys between tables? Some can; most can't. The flat generators that dominate this space produce one table at a time, so a foreign key column fills up with plausible integers that don't necessarily match any row you generated in the parent table. Seedfast reads the live database first and writes rows that reference real parents, which is why an invoice belongs to a customer who exists and a subscription belongs to an account created before it. That difference decides whether a demo survives someone clicking into it or only survives a screenshot. ### Is Mockaroo good for generating demo data? For a single flat table, yes — it's the fastest path to a realistic CSV or SQL export, and for mocking one API response it's hard to beat. The boundary appears when a demo needs several tables to agree with each other — Mockaroo generates each table independently and can only relate tables you rebuild by hand in its own web form, not the ones already defined in your database (as of July 2026, per mockaroo.com), so a multi-table demo turns into exporting each file and wiring the keys yourself. ### How do I generate demo data for a SaaS product without using real customer data? Generate it from your schema instead of copying a slice of production. Seedfast connects to a staging or demo database, reads the tables and their relationships, and writes a fresh set of organizations, users, subscriptions, and activity that never belonged to anyone, which means there is no export of real customers and no anonymization pass for anyone to maintain. Because production rows never leave production, there's no PII in the demo to leak. The generator still reads schema metadata like table and column names, so a regulated team should glance at what those names reveal first. ## Try it against your own schema If your demos keep springing leaks on the first click, more realistic-looking columns won't close them. [Seedfast](https://seedfa.st/) reads your live schema and writes a connected dataset across every related table, so the account you show someone holds together when they start clicking. The [free plan](/pricing) takes no card, and there's no table or seed ceiling on it to trip over. ## Related guides - [Mockaroo alternative](/compare/mockaroo-alternative) — the full side-by-side when your data has foreign keys - [Realistic test data](/blog/realistic-test-data) — the coherence argument, aimed at a test suite - [Staging without production data](/blog/staging-without-prod-data) — the same schema-first approach for staging environments - [Test data generation: 7 methods compared](/blog/test-data-generation) — the toolbox when you need CI data, not a demo --- # MCP Servers for Test Data: What Exists and What Each One Does > Source: https://seedfa.st/blog/mcp-test-data > Last updated: 2026-07-18 Your coding agent is connected, the migrations have run, and the database behind them is empty. Ask around for an MCP server to fix that and the shelf looks crowded — mcpservers.org lists more than 870 in its database category alone ([mcpservers.org/category/database](https://mcpservers.org/category/database)) — but almost none of them do the job you're picturing. They hand the agent a way to run SQL against data that already exists. What you actually want, MCP test data, is the opposite errand, a server that puts realistic, connected rows into a schema that currently holds none. This page surveys the servers that do that, and the much larger category that doesn't, so you can tell one from the other before wiring anything into an agent that can write to your database. It reflects a landscape where the "which MCP gives my agent test data" shelf is still surprisingly thin. ## Access or generation? The line that splits the category Here is the distinction that decides everything downstream. A database-access MCP lets an agent run SQL against data that already exists, inspecting tables, reading schemas, executing queries, and sometimes writing individual rows. A test-data MCP generates the realistic, relationally consistent data in the first place and writes it into the schema. Against an empty database the access kind is useless, since there's nothing there to query, and the generation kind is what fills it. Seedfast sits on the generation side, reading the live schema through the connection you hand it and writing the rows in place. ![Two lanes of MCP server — the database-access kind that queries rows that already exist and idles on an empty schema, and the test-data kind that generates connected rows and fills it](/blog/mcp-test-data-2.svg) Most of those 870-plus database servers are the first kind. Point one at a fresh container or a just-migrated branch and it sits idle, because a query needs rows to return and there aren't any. You could skip the MCP entirely and ask the agent to write the `INSERT` statements itself, which works right up until the foreign keys have to line up across a dozen tables — the point where model-written seed SQL tends to come apart, walked through in the [generate test data with AI](/blog/generate-test-data-with-ai) playbook. ## MCP test data servers at a glance Four options cover almost everyone reading this, and the table below sorts them by what they actually put in your database, not by how they market it. | MCP server | What it does | Pricing model | Best for | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | | Seedfast MCP | Generates connected, realistic rows and writes them into your own database in place, from one `seedfast_run` call | Monthly credits (free plan, then $16 / $69), no per-token meter | Seeding your own Postgres, Supabase, or Neon from an agent or CI | | Tonic Fabricate MCP | Bridges Claude or Cursor to Fabricate's hosted generation agent, which synthesizes data across several engines | Plan + credit/token metering (Plus $29/mo, as of July 2026) | Interactive, multi-engine generation from the editor | | SeedBase Test Data (OSS) | FK-consistent data generator, open source | Free (open source) | Self-hosted setups and OSS-only shops | | Database-access MCPs (Oracle, Microsoft SQL, Cloudflare D1, DBmaestro, Salesforce) | Let an agent query and administer data that already exists; do not generate it | Free or bundled with the platform | Inspecting, querying, or operating a database that already has rows | Generation lives in the top two rows. Everything below only reads data that already exists, which is why that one split matters more than any feature comparison you could run inside either camp. ## Seedfast MCP seeds your database in place Seedfast runs as an MCP server that a coding agent launches over the Model Context Protocol and then calls with a single tool, `seedfast_run`. You describe the data you need in plain language, it reads your live schema, and it writes realistic rows into the database you're already connected to, with the foreign keys resolving so the rows genuinely hang together rather than pointing at records that were never created. Nothing gets shipped out to a hosted service and loaded back afterward; the data lands directly in your own Postgres, whether that's a Supabase branch, a Neon branch, an RDS instance, or a throwaway container spun up for a test run. ![Claude Code calling the seedfast_run MCP tool with a plain-language scope and getting back 213 connected rows across users, projects, tasks, and comments](/blog/mcp-test-data-1.webp) What sets its KIND of MCP apart from the rest of this list is where the work actually happens. Seedfast seeds in place, so the database you connect is the one that fills. Pricing stays flat too, a monthly plan carrying a pool of credits with a free plan under it and no per-token meter, so a seed that fires in CI draws the same as one you run by hand, and because it's CLI-first, the same call the agent makes also drops into a pipeline as a plain command after your migrations. The client config is the same `npx` block across Claude Code, Cursor, and VS Code; the [MCP setup guide](/docs/mcp-setup-guide) carries the per-client version, with focused walkthroughs for [Claude Code](/blog/claude-code-mcp-database-seeding) and [Google Antigravity](/blog/antigravity-mcp-database-seeding). ## Tonic Fabricate's MCP bridges to a hosted agent Tonic launched a Fabricate MCP on July 1, 2026, and it is a real, capable server. Connect Claude or Cursor to it and you can generate synthetic data without leaving the editor, the same conversational flow Fabricate offers in the browser. Where it differs from an in-place seeder is the location of the work. The MCP hands your request to Fabricate's hosted generation agent, which is metered by credit and token, and the generated data comes back to be loaded, rather than the seed running against the database you're connected to. Fabricate's reach is the genuine draw. It connects live databases and generates into Postgres, MySQL, Oracle, and Databricks, with exports to formats an in-place Postgres seeder doesn't touch. If your work spans several engines, that breadth is a real reason to pick it. The cost model is the trade to weigh against it. Generation is metered — a free tier carrying $5/month in credits for a personal email or $10/month with every model unlocked for a work email, a Plus plan at $29/month including $25 in credits, then pay-as-you-go around $0.17 a standard turn (tonic.ai/pricing; figures as of July 2026, worth re-checking before you budget). One practical note for anyone hunting the registries — as of July 2026 the Fabricate MCP wasn't listed on Glama, mcp.so, or Smithery, so you add it from Tonic's own docs rather than a one-click install. The longer head-to-head lives in [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate). ## Open-source and community servers Both registries carry a long tail of community-built servers. Glama alone scans tens of thousands ([glama.ai/mcp/servers](https://glama.ai/mcp/servers)) and groups the relevant ones under a Databases category, while mcpservers.org keeps a parallel list. Most of what you'll find there is the access kind, though a smaller number aim at generation — SeedBase Test Data, listed on Glama, describes itself as generating FK-consistent synthetic data for a database from an AI assistant ([glama.ai/mcp/categories/databases](https://glama.ai/mcp/categories/databases)). For anyone who wants something self-hosted and open-source, the registries are the place to start. Two cautions travel with that choice, though. Quality and upkeep swing widely across a directory that size, so the scan score and the date of the last commit tell you more than the description does. And an agent with a write path into your database deserves the same scrutiny you'd give any dependency, community server or not. ## The bigger category is database-access MCPs The reason the shelf looks so full is that nearly every major data platform now ships an MCP, and they are overwhelmingly access servers. Oracle exposes its database to agents through SQLcl ([oracle.com/mcp](https://www.oracle.com/mcp/)). Microsoft's SQL MCP Server lets an agent work with tables, views, and stored procedures ([Microsoft Learn](https://learn.microsoft.com/en-us/azure/data-api-builder/mcp/overview)). From Cloudflare, the Bindings MCP reaches D1 databases along with the rest of the Workers stack ([blog.cloudflare.com](https://blog.cloudflare.com/thirteen-new-mcp-servers-from-cloudflare/)). DBmaestro's, announced in April 2026, puts release automation and CI/CD orchestration for database pipelines under natural-language control ([InfoQ](https://www.infoq.com/news/2026/04/dbmaestro-mcp-server/)), while Salesforce's surfaces org data and metadata to an agent. Every one of these earns its place, and not one of them generates test data. They let an agent read, query, or operate a database whose rows are already there. Aim any of them at an empty schema and it has nothing to act on; a generation server is what changes that. Plenty of teams keep one registered alongside an access server, because the day you spin up a blank schema is exactly when the query tools have nothing to say. ## Which MCP server should you wire up? Start from what you actually have in front of you. If the database already holds data and you want the agent to inspect it, query it, or run migrations against it, one of the access MCPs above is the whole answer, and bolting on a generator you'll never call is just clutter in the config. If the schema is empty — a fresh container, a branch cloned thin, a demo environment with no rows — you need a generation server, and there the choice turns on interface and cost. For in-place seeding of your own Postgres from an agent or a CI step, on flat pricing, Seedfast is the one I reach for, and since I work on it you should read that as a stated interest rather than a neutral ruling. Fabricate's breadth is hard to beat when you're generating interactively across several database engines from a browser or editor. And if you'd rather vet a self-hosted option yourself, the open-source servers on the registries are worth a look. If you're cross-shopping beyond MCP servers specifically, the [best database seeding tool](/blog/database-seeder) roundup covers ORM seeders and standalone generators side by side. ## Frequently asked questions ### What is an MCP server for test data? A test-data MCP server is one whose tools generate realistic, relationally consistent rows and write them into a database, so an AI coding agent can populate an empty schema by describing what it needs in plain language. It's distinct from the far more common database-access MCP, which only reads or queries data that already exists. Seedfast is one of the generating kind; it reads your live Postgres schema and writes the connected rows straight into it. The Oracle, Microsoft SQL, and Cloudflare D1 servers are the access kind. ### Is there an MCP server that generates data instead of just querying it? Yes, a few. Seedfast generates connected rows and writes them into your own database in place. Tonic's Fabricate MCP generates through a hosted, credit-metered agent. And a handful of open-source servers, such as SeedBase Test Data on Glama, aim at the same job for self-hosted setups. Most database MCP servers, by contrast, only run SQL against data that is already there, which does nothing for an empty schema. ### Does Tonic Fabricate have an MCP server? It does. Tonic launched a Fabricate MCP on July 1, 2026, and connecting Claude or Cursor to it lets you generate synthetic data from the editor. The request runs through Fabricate's hosted generation agent, metered by credit and token (tonic.ai/pricing), rather than seeding your connected database directly. Registry listings haven't caught up yet, so Tonic's own docs are still where you install it from. ### Are there open-source MCP servers for test data? There are. The Glama and mcpservers.org registries list community-built servers under their database categories, and a few of them, SeedBase Test Data among them, sit on the generating side of the split this page draws rather than the querying side ([glama.ai](https://glama.ai/mcp/categories/databases)). Coverage and maintenance vary a lot across a registry that large, so check each server's scan score and recent commit history before you connect it to a database it can write to. ### Which MCP server is best for database seeding? The best MCP server for database seeding depends on where your database lives and how you pay. For seeding your own Postgres in place from an agent or a CI pipeline on a flat plan, Seedfast fits that shape (a stated interest — I maintain it). For generating across multiple engines from a browser or editor, Tonic Fabricate's reach is the stronger fit despite the metered cost. If you want to inspect or query an existing database rather than fill an empty one, you don't want a seeding server at all — an access MCP does that job. ## The short version Strip the 870-server shelf down and it sorts into two piles. Almost everything is an access MCP that reads a database already holding rows. A handful generate the rows instead — Seedfast in place on your own database, Fabricate through its hosted agent, a few open-source servers if you'd rather self-host and vet the code. If the problem you keep hitting is the empty schema an agent stalls on, [Seedfast's free plan](/pricing) is enough to register one config block and watch a database fill from a single prompt, without a card and without any production data touching the run. --- # Pytest Database Fixtures That Stay Fast and Honest > Source: https://seedfa.st/blog/pytest-database-fixtures > Last updated: 2026-07-18 A test that talks to Postgres has to get its data from somewhere, and most pytest suites make that call badly. Either every test rebuilds the whole database and the run crawls, or a pile of hand-written setup fixtures drifts away from the schema until one Tuesday migration turns a wall of green tests red. Pytest database fixtures are supposed to be the fix here, not the source of the pain. They're dependency injection for your tests — a way to declare "this test needs a database in this state" and let pytest wire it up. Things go wrong when a fixture quietly becomes the place you park slow I/O or a stack of hardcoded rows. This guide walks the pieces that decide whether a database suite stays fast and trustworthy, starting with what the fixture scopes actually cost, then the transactional rollback pattern that spares a Postgres suite from truncating tables between tests, then where seeding a realistic baseline belongs so your fixtures stop hauling around data they were never meant to own. Seedfast fills that baseline from the live schema in one command before pytest starts, which is the arrangement everything below is built around. The code assumes SQLAlchemy 2.0 and pytest-django 4.x, the current stable lines of both. ## Key Takeaways - **A fixture is dependency injection, not a hook for slow setup.** The moment a fixture starts rebuilding a database on every call, the scope you gave it is the whole cost of your suite. - Scope is the lever nobody adjusts. A session-scoped connection built once and reused is the difference between a suite that finishes in seconds and one that reconnects to Postgres a thousand times. - Roll back, don't truncate. Wrapping each test in a transaction and rolling it back at teardown gives you a clean slate without touching a single `TRUNCATE`, and SQLAlchemy 2.0 makes the recipe short. - Deferred foreign keys are the trap in that pattern — a constraint marked `DEFERRABLE INITIALLY DEFERRED` only fires at commit, and a rollback fixture never commits, so it can hide a broken row your production code would reject. - **[Seedfast](https://seedfa.st/) seeds a realistic, schema-consistent baseline once**, either locally or as a step before the test job in CI, so pytest's own scoping and rollback handle isolation on top of it. Nothing gets installed into the fixture path, and no test calls out to a generator while it runs. ## Why pytest database fixtures go wrong before they go slow Speed is the symptom people notice, but correctness usually breaks first. A fixture that inserts `user_id=42` and asserts against it works fine until another test deletes user 42, or CI starts its sequences from a different number, or two parallel tests both claim the same primary key. The failure message then reads `expected 3, got 0` and points at application code that has nothing to do with the real problem, which is that the data the test assumed is gone. Shared mutable state is the root of nearly all of it. When several tests read and write the same rows, order starts to matter, and a suite where order matters can't run in parallel. A `tearDown` that deletes what the test created looks tidy until one test raises halfway through, skips its cleanup, and leaves rows behind that quietly poison everything after it. What each test actually needs is a starting state it can trust and a guarantee that nothing it writes leaks into the next one — scope and transactional rollback are how you get both. ## Fixture scopes: function, class, module, session Every pytest fixture has a scope, and scope decides how often the setup and teardown code runs. The pytest docs list five values — `function`, `class`, `module`, `package`, and `session` — ordered from narrowest to broadest. A `function` fixture (the default) is torn down at the end of each test. A `session` fixture is built once when first requested and destroyed only when the whole run ends. ```python import pytest @pytest.fixture(scope="function") # the default — rebuilt for every test def payload(): return {"email": "ada@example.com"} @pytest.fixture(scope="module") # once per test file def api_client(): client = make_client() yield client client.close() @pytest.fixture(scope="session") # once for the entire run def engine(): engine = create_engine(TEST_DATABASE_URL) yield engine engine.dispose() ``` For database work the choice is not cosmetic. Building a SQLAlchemy `Engine` and opening a Postgres connection is expensive enough to dominate the clock on any suite past a few dozen tests, so the engine and any read-only reference data belong at session scope, while the per-test transaction that isolates writes belongs at function scope. The table below is the trade each level makes. | Scope | Setup runs | Reuse is safe when | Reuse bites when | |---|---|---|---| | `function` | Every test | Always — full isolation, no shared state | Never a correctness risk, only a speed one | | `class` | Once per test class | Tests in the class only read the shared value | A test mutates it and a later one depends on the original | | `module` | Once per file | The fixture is a connection, engine, or read-only data | Tests in the file write to it expecting a clean slate | | `session` | Once per run | The value is genuinely immutable or self-cleaning | Any test mutates it — the mutation leaks to every later test | The pattern that holds up keeps the expensive, stable things at a broad scope and layers a narrow-scoped fixture on top for isolation — the trick behind the rollback pattern below. ## The transactional rollback pattern Here is the single most useful pattern for a Postgres test suite, and the one thin tutorials skip. Instead of resetting the database between tests, you open a transaction before each test, run the test inside it, and roll it back afterward. Postgres then discards everything the test wrote and hands the next test the same baseline it started from, without truncating a single table. A rollback is far cheaper than a `TRUNCATE ... CASCADE` plus a reseed, so the suite stays fast as it grows. ### SQLAlchemy: bind a Session to an outer transaction The catch is that ORM code wants to call `session.commit()`, and a naive setup would commit straight through to the database and defeat the rollback, a problem SQLAlchemy heads off with SAVEPOINTs. You begin a real transaction on a raw connection, bind the `Session` to that connection, and tell the session to implement its own `commit`/`rollback` as SAVEPOINTs inside your outer transaction. In SQLAlchemy 2.0 this is one parameter. The official recipe for [joining a Session into an external transaction](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html) passes `join_transaction_mode="create_savepoint"`, and the docs note that the event handlers older versions needed to reset the nested transaction are no longer required. ```python import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session TEST_DATABASE_URL = "postgresql+psycopg2://app:app@localhost:5432/app_test" @pytest.fixture(scope="session") def engine(): engine = create_engine(TEST_DATABASE_URL) yield engine engine.dispose() @pytest.fixture def db_session(engine): connection = engine.connect() transaction = connection.begin() # outer transaction, never committed session = Session(bind=connection, join_transaction_mode="create_savepoint") try: yield session finally: session.close() transaction.rollback() # discard everything the test wrote connection.close() ``` A test that uses `db_session` can insert rows, call `session.commit()` as many times as its code path needs, and read back exactly what committed — all against a SAVEPOINT. When the fixture tears down, the outer `transaction.rollback()` throws the whole thing away. Because the engine is session-scoped and only `db_session` is function-scoped, you pay for the connection a single time while every test still gets its own clean transaction. ![The SQLAlchemy rollback pattern — session.commit() creates savepoints inside an outer transaction the fixture rolls back at teardown, discarding every write](/blog/pytest-database-fixtures-2.svg) ### pytest-django: the django_db marker If you're on Django, pytest-django gives you the same isolation without wiring the transaction yourself. Mark a test with `@pytest.mark.django_db` and, per the [pytest-django database docs](https://pytest-django.readthedocs.io/en/stable/database.html), it runs inside a transaction that is rolled back at the end — the same mechanism Django's own `TestCase` uses. ```python import pytest from django.contrib.auth import get_user_model @pytest.mark.django_db def test_new_user_is_active(): User = get_user_model() user = User.objects.create_user("ada@example.com", password="pw") assert user.is_active ``` This marker has one important exception worth flagging. When your code relies on `transaction.on_commit()` hooks, or a test spins up the `live_server` fixture, or you specifically want to assert real commit-and-rollback behavior, the rollback wrapper works against you, because nothing ever commits and those paths never fire. For those cases pass `transaction=True`, which flushes the database between tests instead of wrapping them: ```python @pytest.mark.django_db(transaction=True) def test_email_is_queued_on_commit(): # on_commit hooks actually run here, because the test really commits register_user("ada@example.com") assert len(mail.outbox) == 1 ``` The docs are blunt about the cost. Flushing the database between tests runs much slower than rolling a transaction back, so reach for `transaction=True` only when you're actually testing transaction behavior, and default to the plain marker everywhere else. ### The deferred-constraint edge case Rollback isolation has one failure mode worth knowing before it bites you in production. A foreign key declared `DEFERRABLE INITIALLY DEFERRED` is not checked when you run the `INSERT`; PostgreSQL's docs state plainly that "DEFERRED constraints are not checked until transaction commit." A rollback fixture never commits its outer transaction, and even the `session.commit()` calls inside a test only release SAVEPOINTs rather than committing the top-level transaction. So a row that dangles a deferred foreign key at nothing will sail through a rollback-isolated test and then blow up the first time real application code commits it. When a test genuinely needs to exercise that deferred check, force it with `SET CONSTRAINTS ALL IMMEDIATE` at the point you want the constraint evaluated, or run that specific test with real commits via `@pytest.mark.django_db(transaction=True)` instead of the rollback path. ```sql -- inside the test's transaction, before you expect the check to fire SET CONSTRAINTS ALL IMMEDIATE; -- a dangling deferred FK now raises here instead of hiding until commit ``` Most suites never hit this, because most schemas don't defer their constraints. If yours does — cyclic references between two tables are the usual reason — keep at least one commit-based test on that path so the rollback pattern isn't silently covering for a bug. ## Seed the baseline once, not per test Rollback keeps tests from stepping on each other, but it puts nothing in the database to begin with. Something still has to establish the realistic starting state your integration tests query against — the customers and their orders, the reference tables they join to — and the wrong place to do that is inside a per-test fixture that regenerates it every time. Seed it once, ahead of the run — one CLI command before you invoke pytest locally, or a step after migrations and before the test job in CI. [Seedfast](https://seedfa.st/) reads your live schema and generates realistic, connected rows that satisfy its constraints, so a single `seedfast seed` fills the whole database with a baseline your tests can read without you hand-writing a row of it. Pytest's scoping and the rollback fixture take it from there, every test seeing the baseline and rolling back its own writes on top. ```python # conftest.py — assert the baseline is present, fail loud if it isn't import pytest from sqlalchemy import text @pytest.fixture(scope="session", autouse=True) def seeded_baseline(engine): with engine.connect() as conn: rows = conn.execute(text("SELECT count(*) FROM customers")).scalar_one() if rows == 0: pytest.exit( "Test database is empty. Seed a baseline first:\n" " seedfast seed --scope 'a few customers with orders and line items'", returncode=1, ) ``` That `autouse` session fixture doesn't generate anything — it checks that the baseline seeded outside the test run actually landed and stops the suite with a useful message if someone forgot to seed. Compare that shape with the alternative some tools push, where every fixture calls out to a generation service at test time and couples your setup to a network call on the hot path. One seed step up front, described in plain English with the [`--scope` flag](/docs/scoping), keeps generation out of the loop, and the [getting-started guide](/docs/seed-your-database) covers `seedfast connect` and `seedfast seed` for the first run. ## factory_boy vs. raw SQL fixtures vs. schema-aware seeding These three approaches solve overlapping problems, and the honest answer is that a mature suite often uses more than one. The question is which job each is actually good at. | Approach | Best at | Cost on a schema change | Foreign keys & constraints | |---|---|---|---| | Raw SQL fixtures | Tiny, fixed reference data you rarely touch | Manual edit every migration; drifts silently | You order the inserts and satisfy every FK by hand | | `factory_boy` | The one specific object a test asserts on | Edit the factory when its model changes | Coded per relationship via `SubFactory` | | Schema-aware baseline (Seedfast) | The realistic bulk every test queries against | None, since Seedfast re-reads the schema each run | Handled from the live schema; values aren't repeatable | Raw SQL fixtures are the fastest thing to write and the first to rot. A hand-maintained `INSERT` script freezes the table shape on the day you wrote it, and the next `NOT NULL` column turns it into a failing insert three layers removed from the test. [`factory_boy`](https://factoryboy.readthedocs.io/en/stable/) earns its place for the object under test — when a test needs "an admin user with a suspended subscription", a factory builds precisely that, with sequences and `SubFactory` relationships, and reads clearly at the call site. ```python import factory from myapp.models import User class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User email = factory.Sequence(lambda n: f"user{n}@example.com") role = "member" is_active = True # in a test: the exact object this test asserts on user = UserFactory(role="admin", is_active=False) ``` Where factory_boy stops paying is the background. Populating fifty tables of realistic, foreign-key-valid data so a reporting query has something to run against means hand-defining a factory per table and keeping each in step with migrations — the volume a schema-aware baseline covers without the upkeep. The two are complementary rather than rival. Seed the connected bulk once, build the single asserted-on object with a factory inside the test, and treat it as [test data management](/blog/test-data-management) split by job rather than one tool doing both. ## When to seed per-test vs. once-per-suite Default to seeding once and isolating with rollback. It's the fast path, and it fits the large majority of integration tests, which read the baseline, write a little, assert, and don't care whether their neighbor's rows exist. Session-scoped baseline plus function-scoped transactional rollback is the shape to reach for first. Re-seeding for an individual test earns its cost only when the test needs a genuinely different data shape than the baseline provides. An empty-state UI test wants a customer with zero orders. For pagination you might need one customer sitting on two hundred, and a permissions test often depends on a specific matrix of roles the baseline doesn't happen to contain. In each of those, build the exact rows with a factory inside a function-scoped fixture, still under the rollback so the special-case data disappears at teardown. The trap is the middle ground where a suite re-seeds the whole database per test out of caution — that's how a five-minute run becomes forty. If a test queries for "a customer with at least one order" instead of assuming customer 42, it can share the baseline, and most tests can be written that way with a little discipline about not hardcoding IDs. The [E2E test fixtures](/blog/e2e-test-fixtures) guide works the same principle from the browser-testing side. ## Running it in CI The CI story is short because most of it isn't pytest-specific. You bring up a Postgres service container, gate it on `pg_isready` so no step races a database that isn't listening, migrate, seed the baseline, then run pytest. The seed step lands last, immediately before the tests read the database. The YAML below uses Seedfast as that seed step; any seed script slots into the same position. ```yaml - run: pip install -r requirements.txt - run: alembic upgrade head # migrate first env: DATABASE_URL: postgresql+psycopg2://app:app@localhost:5432/app_test - run: npx seedfast seed --scope "a few customers with orders and line items" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: postgresql://app:app@localhost:5432/app_test - run: pytest -q # then read the database env: TEST_DATABASE_URL: postgresql+psycopg2://app:app@localhost:5432/app_test ``` ![GitHub Actions job where alembic upgrade head, seedfast seed, and pytest -q run in order and finish green against a Postgres service container](/blog/pytest-database-fixtures-1.webp) The full mechanics of the service container, the health-check gate, and the migrate-then-seed ordering get their own walkthrough in [seeding a Postgres test database in GitHub Actions](/blog/github-actions-seed-postgres-database) — there's no reason to repeat it here. The Seedfast-specific CI setup, including the API key and non-interactive scoping, lives in the [CI/CD database seeding docs](/docs/cicd-database-seeding). Whichever way you seed, the running order is the load-bearing part, and this is [database seeding](/blog/database-seeding) applied to a pytest job like any other. ## A complete conftest.py, end to end The complete file below wires together a session-scoped engine, an `autouse` guard that fails fast when the baseline is missing, and the function-scoped pytest Postgres fixture (`db_session`) that every test depends on. Drop it at the root of your test tree and the whole pytest test database setup fits in one file: seed once before the run, and each test gets the baseline plus perfect isolation. ```python # conftest.py import os import pytest from sqlalchemy import create_engine, text from sqlalchemy.orm import Session TEST_DATABASE_URL = os.environ.get( "TEST_DATABASE_URL", "postgresql+psycopg2://app:app@localhost:5432/app_test", ) @pytest.fixture(scope="session") def engine(): """Built once for the whole run — opening connections is the expensive part.""" engine = create_engine(TEST_DATABASE_URL) yield engine engine.dispose() @pytest.fixture(scope="session", autouse=True) def seeded_baseline(engine): """Assert the baseline seeded before pytest ran (locally via the CLI, or a prior CI step) is actually present. Fail loud rather than run empty tests.""" with engine.connect() as conn: customers = conn.execute( text("SELECT count(*) FROM customers") ).scalar_one() if customers == 0: pytest.exit( "Test database is empty. Seed a baseline first:\n" " seedfast seed --scope 'a few customers with orders and line items'", returncode=1, ) @pytest.fixture def db_session(engine): """Function-scoped session bound to an outer transaction that is always rolled back. Tests may call session.commit() freely — it commits to a SAVEPOINT, and the outer rollback discards everything at teardown.""" connection = engine.connect() transaction = connection.begin() session = Session(bind=connection, join_transaction_mode="create_savepoint") try: yield session finally: session.close() transaction.rollback() connection.close() ``` A test then reads like plain application code, and the isolation is invisible: ```python def test_order_total_sums_line_items(db_session): customer = db_session.execute( text("SELECT id FROM customers LIMIT 1") ).scalar_one() db_session.execute( text( "INSERT INTO orders (customer_id, status) " "VALUES (:cid, 'open') RETURNING id" ), {"cid": customer}, ) db_session.commit() # commits to a SAVEPOINT, not the real database open_orders = db_session.execute( text("SELECT count(*) FROM orders WHERE status = 'open'") ).scalar_one() assert open_orders >= 1 # teardown rolls the outer transaction back — the next test never sees this order ``` Run it, and the moment the fixture tears down, the order this test created is gone. There's nothing to clean up or truncate, and the baseline the whole suite reads against sits exactly as `seedfast seed` left it. ## Frequently asked questions ### Do I need a pytest plugin to seed a database? No. Seeding and test isolation are separate jobs, and pytest already handles the isolation half natively through fixture scopes and transactional rollback. For the seeding half you only need something that fills the database before the run — a CLI command locally, a step in CI — not a plugin wired into your fixture path. Seedfast runs as that one seed step and leaves the fixtures to pytest, so there's nothing extra to install into the test process itself. ### What's the difference between a pytest fixture and database seeding? A fixture is a pytest construct that provides a test with what it needs and cleans up afterward — a connection, a session, an object under test. Seeding is the separate act of putting a realistic starting dataset into the database before any of that runs. A fixture can assume the seeded data exists and query it, but it shouldn't be regenerating that dataset on every test, which is what makes a suite slow. ### Should I use transaction rollback or truncate tables between tests? Prefer rollback. A rollback discards the test's writes in one step, while a truncate-and-reseed pays table-by-table I/O between every pair of tests. With SQLAlchemy's `join_transaction_mode="create_savepoint"` your ORM code can still call `commit()` freely inside that rollback. Truncation is only the better tool when you must test real commit behavior (`on_commit` hooks, visibility across connections), which is exactly the case pytest-django covers with `@pytest.mark.django_db(transaction=True)`. When it is, every table belongs in one statement rather than a loop, a list [the TRUNCATE generator](/tools/postgres-truncate-all-tables) writes from your schema. ### Does factory_boy replace database seeding? Not for the bulk of your data. factory_boy is excellent for building the specific object a test asserts on, with sequences and `SubFactory` relationships. It's a poor fit for populating fifty foreign-key-linked tables of realistic background data, because that means writing and maintaining a factory per table against every migration. Seed the connected volume once with Seedfast, which reads the live schema and fills those tables for you, then use factory_boy for the one-off object each test cares about. ### How do I seed a Postgres test database for pytest in CI? Run the seed step after migrations and before pytest. Bring up a Postgres service container, gate it with `pg_isready`, apply your migrations, then run `npx seedfast seed` against the CI database with `SEEDFAST_API_KEY` and a connection string, and finally invoke pytest pointed at the same database. The [GitHub Actions guide](/blog/github-actions-seed-postgres-database) covers the container and health-gate mechanics, and the [CI/CD database seeding docs](/docs/cicd-database-seeding) cover the key and non-interactive scoping. ## Where to take it from here The shape that holds up is layered. At the bottom sit a session-scoped engine and a baseline seeded once; on top of them, function-scoped transactional rollback provides isolation while a factory handles the single object a given test asserts on. Get that stack right and your fixtures stop being a maintenance surface — they go back to being plain dependency injection, which is all they were ever meant to be. If the "seed a realistic baseline once" step is the piece you're missing, point Seedfast at a local or CI database and let it read the schema and fill it. The [getting-started guide](/docs/seed-your-database) takes you from `seedfast connect` to a seeded database, and the [free plan](/pricing) doesn't ask for a card. --- # Vibe Coding Is Great Until It Hits the Database > Source: https://seedfa.st/blog/vibe-coding-database > Last updated: 2026-07-18 You describe the app, the agent writes it, and for a while it genuinely feels like magic. Then you open the thing you built and every screen is empty. The dashboard shows no numbers, the list view spins and resolves to nothing, and the one account you can log in as has no orders and no history behind it. The code runs fine, which is the confusing part, because the real problem is the database, sitting there either as empty tables or filled with rows like `user1@test.com` and a product literally named "Test Product." The vibe coding database problem is easy to misread, because two very different things hide behind that empty screen. One is structure, meaning the tables, columns, and relationships, and the agent handles that part well. What it fakes is the other half, the data meant to fill those tables, and a faked database is one you can't really test or show to anyone. ## What vibe coding gets right about the schema Here's the part that surprises people who've been hand-modeling databases for years. Coding agents are good at schema design. Describe a marketplace with sellers, listings, orders, and reviews, and you'll get back a set of tables with sensible types, primary keys, and foreign keys wired roughly the way you'd have drawn them. For a greenfield app, letting the agent draft the schema is a reasonable default, and it gets you to something running faster than modeling every table by hand. An agent reliably gives you tables and columns, and gets shakier on the constraints, the rules that keep the data honest. Does `orders.user_id` actually carry a foreign key, or is it just an integer column named like one? The unique flag on `email`, the `NOT NULL` markers, and the delete rules all deserve the same suspicion, since cascading a user delete straight into their paid invoices is rarely what anyone wanted. This stuff reads as boring right up until the day it lets a broken row into your database, and that is exactly why skimming the generated migration yourself, for the two minutes it takes, is the highest-leverage review you'll do all week. ## Keep migrations in files, not chat Migrations are where a lot of vibe-coded projects quietly rot, and the reason is nearly always the same. The agent changes the schema by running SQL straight against your database, adding a column here and dropping one there, all inside the conversation. It works in the moment, but a few days later the shape of your database lives only in a chat transcript you've long since scrolled past, with no clean way to rebuild it from zero or hand it to a teammate. The fix here is a habit rather than a tool. Tell the agent that every schema change goes into a migration file, committed to the repo and applied with whatever runner your stack already has, whether that's Prisma Migrate, Drizzle Kit, or Django's migrations. Agents are happy to work this way once you ask, and they'll write the migration, name it, and run it. They won't hold themselves to the discipline, though, so that part stays with you. Pasting the file into [the Postgres syntax checker](/tools/postgres-syntax-checker) before you apply it costs a few seconds, and a migration that does not parse is better caught there than against a live schema. Once the migrations live in files, your database has a history you can replay, and the schema is no longer something only the current chat session remembers. ## The vibe coding database problem is the data The genuinely hard part starts when you ask a coding agent to seed your database and it cheerfully writes a loop that inserts `user1@test.com` through `user20@test.com`, every one carrying the password `password123`, all created the same afternoon, none of them connected to anything else. The tables technically have rows now, but in practice you're left with a database that demonstrates nothing. ![A marketplace admin panel running on placeholder rows — sellers named User 1 through User 10 with user1@test.com emails and a single listing called Test Product](/blog/vibe-coding-database-1.webp) Two things go wrong, and they compound as the app grows. The first is realism, and real tables have spread in them, old accounts next to brand-new ones, a few heavy users with hundreds of records against a long tail with almost none, names and emails that vary the way real ones do. Placeholder data is flat, and flat data hides the exact bugs you'll ship, like pagination that breaks on page two, a query that's instant on ten rows and crawls on ten thousand, or a report that looks correct until one customer turns out to have zero orders. The distance between placeholder rows and [realistic test data](/blog/realistic-test-data) is most of what makes a vibe-coded app feel dead on arrival. [Tidy little datasets tell reassuring lies](/blog/small-data-big-lies), convincing precisely because everything inside is so uniform. Consistency across tables is the second problem, and it only gets worse as the schema grows. Every `order` has to point at a `user` and a `product` that both exist. Add an `order_item` and it needs the `order` above it and a `price` beside it, while a `review` only makes sense if it belongs to someone who actually bought the thing. Holding all of that together is [referential integrity](/blog/referential-integrity), and it's the kind of bookkeeping a language model loses the thread of once the schema runs more than two or three levels deep. Neon tested this in the open, [pointing AI models at synthetic data generation](https://neon.com/blog/vibe-coding-with-ai-to-generate-synthetic-data-part-1) and watching them keep up while the schema stayed shallow, then drift once the foreign-key graph deepened and there was more to hold consistent than a model could carry in its head. An empty database and a junk-filled one fail you in the same spot. You can't meaningfully test a vibe-coded app against either, because the tests pass whenever nothing in the data contradicts them, and you certainly can't demo it. No investor call or onboarding walk-through survives a product tour where every chart plots a single point and half the records read "Test Product." ## How to get real data into a vibe-coded app Getting real data in takes a handful of steps, roughly ordered by effort. Start with the reference data you keep by hand. The rows the app needs just to boot (roles, categories, plan tiers, country codes) are small, stable, and worth writing deliberately. Put them in a real seed file or a migration, keep them in version control, and let that be the one slice of your data you maintain yourself. Everything else gets generated from the schema. The users, the orders, the content that makes the app look inhabited, none of that should be hand-typed or improvised row by row by the agent. Filling those tables is [database seeding](/blog/database-seeding), and the durable way to do it is to have a tool read your schema and produce rows that fit it, rather than inventing values blind. Seedfast reads the structure you already have, the tables and columns and foreign keys, then writes rows that satisfy it, so an `order` lands on a `user` and a `product` that exist and the set stays consistent without you wiring IDs together. Don't fix the empty database by pointing the agent at a copy of real production data, tempting as that shortcut is. Past the obvious privacy problem, [an autonomous agent with write access to a live production database is a blast radius you don't need](/blog/ai-agent-production-database) this early in a project. This is the shape [Seedfast](https://seedfa.st/) is built for. It's data infrastructure that reads your live schema and generates referentially consistent, realistic data from it in a single command: ```bash seedfast seed --scope "marketplace with 200 sellers, listings, and orders" ``` ![The same marketplace admin after a Seedfast run — 200 sellers with varied names and joining dates, thousands of listings and orders, and a populated top-listings panel](/blog/vibe-coding-database-2.webp) Same screen, different database — the User 1 rows are gone and the top-listings panel finally has something to rank. You describe the scenario in plain English, the same way you've been describing everything else to your agent, and the schema fills in the structure. Because it reads the Postgres catalog itself, it doesn't much matter whether Prisma, Drizzle, or something else defined your tables, and the run goes straight at the database rather than through the ORM that wrote it. When your next migration reshapes things, the following run picks up the new shape instead of breaking on the old one. It fits vibe coding especially well because your agent can drive it for you. Seedfast is a CLI the agent can call like any other command, and it exposes an MCP tool an agent can invoke directly, so instead of writing you one more `user1@test.com` loop, the agent hands the relational work to something built for it and gets a full, connected dataset back. You stay in the same describe-and-iterate rhythm you started in, and the data stops being the weak link in the loop. For the agent-side mechanics, there's a full walkthrough of [how to generate test data with AI](/blog/generate-test-data-with-ai). ![A coding agent asked to replace a user1@test.com seed loop hands the job to the seedfast_run MCP tool and reports 3,200 connected rows back](/blog/vibe-coding-database-3.webp) ## A database workflow that survives past the demo Put the three pieces together and the database stops being the scary part of vibe coding. Let the agent draft the schema, but read the constraints before you trust them. Every schema change then goes through a migration file in version control rather than the chat window, and data gets a step of its own, a small reference seed you write by hand plus generated data for the volume. A database built that way comes up populated and consistent every time you rebuild it. You can run real tests against it, and when it's time to put the product in front of someone, it behaves like an app rather than a fixtures file. ## Frequently asked questions ### Can Cursor or Claude Code set up my database? Yes, for the structure. Cursor, Claude Code, Lovable, and Bolt are all good at drafting a schema and writing migrations from a plain-English description, and for a new project that's a fine place to start. Do check the generated constraints, the foreign keys and unique columns and NOT NULL rules and cascade behavior, because that's where agents are least dependable. Filling those tables with usable data is a separate problem, and it's the one they handle far worse. ### How do I get realistic test data into a vibe-coded app? Don't have the agent hand-write insert statements, since you'll end up with flat placeholder rows that don't line up across tables. Keep your true reference data, things like roles and categories and plan tiers, in a version-controlled seed file, then let Seedfast generate the rest from the live schema so the foreign keys resolve and the distributions come out with real spread in them. That gets you a database realistic enough to test and demo against without babysitting a seed script. ### Why does my vibe-coded app look empty or broken? Nearly always because the database has no usable data in it. Your code renders whatever the tables hold, so empty tables give you empty dashboards and blank lists, and placeholder rows like `user1@test.com` give you screens that technically load but show nothing convincing. The fix is usually to populate the database with connected, realistic data, not another round of edits to the application code. ### Should I let the AI agent connect to my real database? For a scratch or local database, go ahead. Be more careful with anything holding real user data, because an agent runs unattended and can do damage quickly, so the safer pattern is to hand it a disposable database seeded to look like production instead of production itself. There's [more on keeping AI agents off production databases](/blog/ai-agent-production-database) if you're weighing the trade-off. ## Get a populated database in one command Your agent can mostly take care of the schema and the migrations. Data is the piece that stays on you, and it decides whether the app you vibe-coded actually holds up when you test it or put it in front of someone. [Seedfast](https://seedfa.st/) is built to take that piece off your plate, so you point it at the Postgres database behind your app, describe the scenario you want, and get realistic, connected rows back in it. The [free plan](/pricing) doesn't ask for a card, which means you can watch an empty database fill up before deciding it's worth keeping around. --- # Agentic QA Can Write the Tests. It Still Can't Invent the Data. > Source: https://seedfa.st/blog/agentic-qa-test-data > Last updated: 2026-08-19 An agentic QA tool reads your app, decides what's worth testing, writes the test, runs it in a real browser, and rewrites it the next morning after someone renames a button. Autonoma, QA Wolf, Momentic, and testRigor all sell a version of that loop today, and it's fair to conclude that writing and maintaining test scripts has stopped being your job. What the conclusion skips is the agentic QA test data problem sitting one layer down, in the database each of those tests runs against. None of these tools invents that database. They drive an application that already has to be full of valid, connected, believable rows, and the question of where those rows come from is one every agentic QA product still hands back to the team that bought it. The authoring of the test moved off your plate; the authoring of the data underneath it never did, and the two stay easy to confuse right up until a green suite ships a bug that only a realistic row would have caught. That lower layer has tooling of its own. [Seedfast](https://seedfa.st/) reads the Postgres schema on the connection you hand it and writes fresh values into every column, so the database a QA agent drives comes out connected and valid before the first assertion runs, and one run scopes down to a single table or out to a schema several hundred tables wide. Why the QA vendors leave that job alone is worth a closer look. ![Three-layer stack: an agentic QA layer that plans, writes, and self-heals the test is automated; beneath it, the test runs against a database that must already be full of valid, connected rows — the gap, still hand-built from seed scripts and fixtures; a schema-aware generator like Seedfast fills that layer automatically by reading the schema and generating valid, connected data.](/blog/agentic-qa-test-data-1.svg) ## What agentic QA actually automated For a decade, end-to-end tests were written by hand and broke by hand. An engineer authored a Playwright or Cypress script, a selector shifted, the script went red, and someone lost an afternoon nursing [brittle, hand-maintained fixtures](/blog/e2e-test-fixtures) back to green. Agentic QA collapses that cycle, and it's worth being specific about how far these products actually go, because they are good at the thing they set out to do. Autonoma runs the loop as a set of cooperating agents. A Planner reads the app or its codebase and generates the test cases, an Automator drives real browsers to execute them against a live preview environment, and a Maintainer notices when the UI has moved and repairs the affected tests on its own (getautonoma.com, May 2026). QA Wolf takes the managed-service route, generating and running Playwright and Appium suites so that web and native mobile flows both get covered without a team writing the automation code. testRigor lets you describe a test in plain English and compiles that description into a runnable case, which dodges brittle selectors by never asking a human to write one in the first place. Momentic sells the same core promise, an agent that authors and maintains the tests so QA engineers stop babysitting scripts; one agent-market tracker put its total funding at a reported $18.7 million, with Notion, Xero, Webflow, and Retool among its users (agentmarketcap.ai, April 8 2026). The architectures differ; the shared win doesn't. The test itself, from first intent through running assertion to self-healing upkeep, is now something a team can delegate to software, and that is a real bottleneck cleared. ## The step every one of them still hands back to you Ask any of these tools where the test data comes from and the honest answers converge on the same place, which is you. Autonoma has the most developed answer in the category, and it deserves a careful description precisely because it's strong. The product ships an Environment Factory, and its own copy is candid about the mechanism. "You connect your own create and delete functions through our SDK", it reads, "so Autonoma seeds and tears down exactly like your app does — password hashing, foreign keys, business rules and all" (getautonoma.com, as of July 2026). Underneath that, per its docs (docs.autonoma.app, as of July 2026), is "one endpoint in your backend that creates isolated test data before each run and tears it down after", so every scenario starts from a clean, correct state. Because the setup runs through your application's own create logic, the rows it produces obey the same invariants production does, which is more than a generic fixture ever gives you, and it's the reason Autonoma's answer is the most credible one going even though it stops short of generating anything at all. It is also, precisely, not data generation. The Environment Factory calls a function your engineers already wrote, for every entity your tests touch, before the agent can use it — the knowledge of what a valid customer or a well-formed order looks like lives in code your team authored and maintains. Autonoma orchestrates that code well; it doesn't originate the data. The other tools sit further from generation. A competitor's April 2026 comparison characterizes QA Wolf's database setup as something that "requires coordination with QA team or manual seed scripts" (getautonoma.com, April 2026), which is one vendor's read of a rival and worth treating as positioning. QA Wolf's own page frames a hybrid platform and service, where a team can run the tests itself or lean on QA Wolf's engineers, and neither mode claims schema-driven data generation. testRigor does ship a data feature, and its scope is the tell. The site says it "allows to easily generate unique test data based on specified format or Regex" ([testrigor.com/features](https://testrigor.com/features/), 2026). One value, one column, matched to a pattern, with no notion of the foreign key tying that column to the next table over. It's Faker's ceiling relocated inside a test-authoring product, handy for a single field and silent about the graph. ## Why doesn't an agentic QA tool just generate the test data itself? Because it's a different problem, owned by a different kind of software, and none of these vendors is in that business. A QA agent's competence is behavioral. It models what a user does and reasons about the app's visible surface, the routes and forms and states a browser can reach. Nothing in that job requires it to hold a model of how your schema's tables relate to each other or how your domain's values are really distributed, so it doesn't build one. Momentic's own December 2025 writeup of what its agents do runs from test authoring through maintenance without once mentioning the database underneath the tests ([momentic.ai, Dec 29 2025](https://momentic.ai/blog/ai-agents-in-qa-testing)); in that post, at least, the data layer never comes up. Generating a realistic multi-table dataset is the opposite kind of task, and the steps don't commute: read the schema, satisfy every foreign key and check constraint, then shape the values so they read like production instead of like `random()`. That's [the same constraint-solving problem a language model hits](/blog/generate-test-data-with-ai) when you ask it to emit the inserts directly, and it's why the sharper question is often [which database an agent gets](/blog/ai-agent-production-database) at all. Solving it is a product in itself, and not the product any agentic QA vendor set out to build. It's the one Seedfast did. ## Where Seedfast fits underneath an agentic QA tool The fix isn't to wait for one of these vendors to grow a data engine. It's to put the missing layer directly beneath the one they already automated. Seedfast is that layer, and it does the thing the QA agent structurally can't. Give it a connection string and it works from the schema on the other end, filling every column and leaving nothing pointed at a row that isn't there, tables that reference themselves or each other included when the schema leaves a nullable link in the loop. None of what it inserts is copied from production. What Seedfast reads is schema metadata, the table and column names and constraints, never the rows inside them, though teams in regulated settings will still want to review what those names reveal. What you get is a database that behaves like production without ever having held a byte of it, standing ready before any agent, QA or coding, runs its first assertion. Stacked, the division of labor is clean. The agentic QA tool owns the question of whether the app behaves correctly, while Seedfast owns the question of whether the database it behaves against looks anything like production. You seed first and let the agent test second. Run Seedfast as a CLI step in the job that provisions the environment, or, because it also runs [as an MCP tool](/docs/mcp-setup-guide), let a coding agent trigger the seed itself before it hands the app off to the QA layer. A GitHub Copilot agent whose Actions environment comes up empty on every run needs the same seed wired into its setup workflow before a single test has data to run against, because [an empty database the agent can't tell is a bug](/blog/ai-agent-empty-database) will pass every assertion it runs anyway. Neither tool competes with the other, and neither stands in for it. And because Seedfast is [flat-priced rather than metered per row](/compare/test-data-tool-pricing), it costs less than the flaky-suite afternoon it replaces. ## Frequently asked questions ### What is agentic QA? Agentic QA is testing where an AI agent plans, writes, runs, and maintains the tests itself, reading the app or its code to decide what to cover, driving a real browser the way a person would rather than binding to fragile CSS selectors, and rewriting a test when the interface it targeted moves. The label is the vendors' own coinage, the phrase Autonoma, QA Wolf, and Momentic all reach for to name their category in 2026 marketing, which is a fair sign of how fast this became a recognized product class. ### Does Autonoma generate its own test data? No, and it doesn't claim to. Autonoma's Environment Factory calls create and delete functions your own engineers write and register through its SDK, which ships for TypeScript, Python, Elixir, Java, Ruby, Rust, Go, and PHP as of July 2026 (docs.autonoma.app). Reusing your app's real logic is why the rows come out valid, but the intelligence that knows what a valid record looks like is yours, not the agent's. It orchestrates your setup code; it doesn't invent data from your schema. ### Can testRigor or QA Wolf generate relational test data? Not in the relational sense. testRigor's data feature tops out at one value matched to a format or regex, useful for filling a field but blind to whether that field has to match a row in another table. QA Wolf describes a hybrid platform and service whose scope covers API setup, database state management, SMS verification, native mobile execution, and multi-user workflows ([qawolf.com, 2026](https://www.qawolf.com/blog/the-12-best-ai-testing-tools-in-2026)); whether a team runs the tests itself or leans on QA Wolf's engineers, nothing in that scope generates relational rows from a schema. Seedfast covers that part, reading the schema and filling the tables in a step that runs before either product opens a browser. ### How does Seedfast fit alongside an agentic QA tool? It runs one layer down and one step earlier. You stand up a throwaway Postgres, run your migrations, and point Seedfast at the connection string to fill it with connected rows, and only then hand that database to the QA agent to test against. Whether the agent is a browser-driving QA tool or a coding agent that seeds itself over MCP first, the sequence holds, and the QA tool never has to care where the rows came from, only that they're present and valid. The [free plan](/pricing) needs no card, so evaluating the data layer doesn't require a second procurement conversation. ## Where agentic QA test data actually comes from An agentic QA tool tells you whether your app behaves, but only against the rows it happens to find, and putting realistic rows there was never its job. That gap runs through every vendor in the category, which is why the durable setup stacks two automations instead of waiting for one to absorb the other. Seedfast seeds the database, the QA agent tests it, and neither pretends to do the other's work. --- # Seed a Postgres Test Database in GitHub Actions > Source: https://seedfa.st/blog/github-actions-seed-postgres-database > Last updated: 2026-09-10 Every CI job starts with an empty database. The service container comes up with a fresh Postgres inside it, nothing carries over from the last run, and whatever your tests expect has to be put there between checkout and the test runner. That leaves a short, well-worn list of ways to make GitHub Actions seed Postgres database state for your tests, and most test-pipeline pain traces back to which one a team reached for. This is the platform-mechanics page. It covers the service container GitHub Actions hands you, the health gate that stops a seed step from racing a database that isn't listening yet, the migrate-then-seed order, and the three real ways to fill the thing once it's up. [Seedfast](https://seedfa.st/) is the third of those, reading the schema your migration step just produced and generating rows into it as one pipeline step. [The CI/CD database seeding docs](/docs/cicd-database-seeding) own the end-to-end wiring, and the case for generating data rather than copying it lives in [synthetic data for CI/CD](/blog/synthetic-data-ci-cd). Here the subject is the plumbing, [database seeding](/blog/database-seeding) applied to a GitHub Actions runner. > **TL;DR:** A GitHub Actions service container gives every job a throwaway Postgres. Gate it with `--health-cmd pg_isready`, run migrations, then seed, in that order. Three approaches fill it. Restoring a dump is stale and heavy, an ORM seed script needs upkeep as the schema grows, and Seedfast reads the structure fresh on each run without promising repeatable values, so pin the few rows a test asserts on in a checked-in reference file. ## The Postgres service container GitHub Actions tears down every run GitHub Actions runs a [service container](https://docs.github.com/en/actions/using-containerized-services/about-service-containers) alongside your job and throws it away when the job ends, which means the Postgres you get is genuinely disposable, empty at the start of every run and never carrying a stray row from one build into the next. You declare it under `services:`, pin an image, and hand it a health check so the runner knows when the database is truly accepting connections, not just booted. ```yaml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - name: Run migrations run: npm run migrate env: DATABASE_URL: postgres://app:app@localhost:5432/app # --- one of the three seed steps below goes here --- - run: npm test env: DATABASE_URL: postgres://app:app@localhost:5432/app ``` Your own values replace `app:app` and the database name, and where the target is a managed instance with an `sslmode` and a non-default port, [the Postgres connection string builder](/tools/postgres-connection-string-builder) assembles the URI out of the separate parts. The `options:` block does the load-bearing work here. Docker calls a container "running" the instant its process starts, but Postgres spends a beat finishing recovery before it opens its socket, and a migration step that fires into that window dies on "connection refused". `--health-cmd pg_isready` closes the gap, holding your steps back until `pg_isready` exits clean, so "healthy" and "ready to connect" become the same event. The hostname is what trips people. This workflow sets no `container:` key, so its steps run on the runner VM and reach Postgres over `localhost:5432` through the `ports:` mapping. Set `container:` on the job instead, running your steps inside `node:22-bookworm-slim`, and it flips the other way — you drop the `ports:` mapping and connect to `postgres:5432`, the service label, because containers on the same Actions network resolve each other by name. Getting that backwards is the most common "connection refused" here, and [GitHub's own PostgreSQL service-container docs](https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers) spell out both cases. The same service block powers other Actions contexts too; the [GitHub Copilot coding agent](/blog/copilot-coding-agent-database-seeding) runs it inside `copilot-setup-steps.yml`. This whole workflow runs green in a [public demo repo](https://github.com/seedfast-ai/github-actions-demo) you can fork — the Postgres service container comes up, the schema applies, and the seed step reports every table it filled: ![A successful GitHub Actions run titled "seed a Postgres service container in GitHub Actions with Seedfast": the postgres service initialised, the wait-for-Postgres and apply-schema steps green, and the expanded Seed with Seedfast step logging 498 records across 5 tables in scope, ending in 5 of 5 tables succeeded.](/blog/github-actions-seed-postgres-database-1.jpg) ## Migrate first, then seed Order is not negotiable here. A seed step against a schema that hasn't been created yet fails outright, with no table to write into, and a seed that somehow runs before the latest migration bakes yesterday's shape into today's rows. Migrations create and alter the tables, the seed fills them, and only then do the tests read them. Keep that sequence and the seed always lands last, immediately ahead of the test runner. ```yaml steps: - run: npm ci - run: npm run migrate # create/alter tables first - run: # then fill them - run: npm test # then read them ``` The migration command is yours; `npm run migrate` stands in for whatever your migration tool does. What matters is the position, not the tool. ## Three ways to fill the container Once Postgres is up and migrated, something has to put rows in it, and the field narrows to three approaches. Each works, and each fails in its own way as a project grows, so it pays to see all three side by side before committing. | Method | Lives in the repo | Survives the next migration | Deterministic | |---|---|---|---| | Dump restore | Yes (often huge) | No | Yes (frozen snapshot) | | ORM seed script | Yes | Partly (hand-written parts need edits) | Yes if seeded or hard-coded | | Schema-aware generator (Seedfast) | No file to keep | Yes | No (values vary per run) | ### The dump-restore trap The first instinct is often to restore a dump. You `pg_dump` your dev or staging database, commit the file, and replay it with `psql` at the top of the job. Should that dump have started life in production, every reason to [keep production rows out of staging](/blog/staging-without-prod-data) applies to the CI runner as well. ```yaml - name: Restore dump run: psql "$DATABASE_URL" -f fixtures/test_dump.sql env: DATABASE_URL: postgres://app:app@localhost:5432/app ``` It runs the first time. A migration lands, and the dump — a snapshot of the schema on the afternoon you ran `pg_dump` — is suddenly describing a database you no longer have. From there it either errors on the mismatch outright or, worse, loads clean and hands your tests a shape they shouldn't trust. Then there's the weight. The file drags along every row that existed when you took it, nearly all of which no test will ever read, and dumps bloat fast enough that teams end up parking them behind Git LFS just to keep a few hundred megabytes of fixtures in the repo. None of that means a dump is useless. Reach for one to reproduce a specific bug and it earns its place. As a standing source of test data, though, it's the heaviest option on this list and the one that goes stale fastest. There is a narrower case where production-shaped data really does pay off, and [E2E test fixtures](/blog/e2e-test-fixtures) works through it. ### The ORM seed script If your app already speaks Prisma or Drizzle, a seed script in the same ORM is the natural next reach, and for good reason. Prisma's `db seed` hook runs a TypeScript file that inserts through the same client your app uses, so the data matches your models; the [Prisma seeding deep dive](/blog/prisma-postgres-seed) covers the script itself. Drizzle pairs with `drizzle-seed`, which reads your Drizzle schema and generates rows through a seedable RNG; hand it a fixed `seed` number and the same rows come back every run, a genuinely useful property the other two methods here don't offer. ```yaml - name: Seed with Prisma run: npx prisma db seed env: DATABASE_URL: postgres://app:app@localhost:5432/app ``` ```yaml - name: Seed with Drizzle run: npx tsx db/seed.ts env: DATABASE_URL: postgres://app:app@localhost:5432/app ``` ```ts // db/seed.ts import { seed } from "drizzle-seed"; import { db } from "./client"; import * as schema from "./schema"; await seed(db, schema); ``` Both carry a maintenance bill, though not the same one. A hand-written Prisma seed drifts from the tables it targets; add a `NOT NULL` column in a migration and the insert that omits it starts failing, the exact scenario [a hand-rolled Postgres seed script](/blog/postgres-seed-script) reproduces against a live example with a `visibility` column. drizzle-seed re-reads the schema object, so a new column gets filled on the next run; what still needs a human is per-column refinements and pointing the script at newly added tables. On a ten-table schema that's a minor chore, while across fifty tables with real foreign-key depth it turns into its own backlog, the [seed file quietly rotting](/blog/seed-file-maintenance) one migration at a time. The [drizzle-seed comparison](/compare/drizzle-seed-alternative) traces where that curve bends. On a small, stable schema, a seed script is honest work. It's what happens to that script as the schema grows that costs you. ### The schema-aware generator (Seedfast) The third option removes the file. Rather than storing rows or a script that writes them, Seedfast reads the database structure at the moment the step runs and produces rows to fit what it finds. You add it as one step, the same shape as the two above. ```yaml - name: Seed with Seedfast run: npx seedfast seed --scope "customers with a few orders and line items each" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: postgres://app:app@localhost:5432/app ``` The `--scope` string describes what you want in plain English, and because it doubles as the approval, the run never stalls waiting on an interactive prompt. `SEEDFAST_DSN` points at the database your migration step just built; the CLI reads it before `DATABASE_URL`, so it won't collide with whatever another tool already put there. The key comes from a [free plan](/pricing) that doesn't ask for a card. What travels over that connection is structure — the table and column names, the types, the constraints — never the rows themselves. Since that structure is read at the moment the step runs, a migration you shipped in the same branch shows up in the rows that come out, and there's no seed file to touch when a new column lands. Seedfast fills looped tables too, circular references included, keeping referential integrity intact and sparing you the deferred-constraint SQL a [circular foreign key seed](/blog/circular-foreign-key-seed) otherwise needs. Reading the live schema on every run is the one trade the other two can't make. A dump and a hand-written script both freeze at the moment you last touched them. Seedfast doesn't, and the price it pays for that is values you can't reproduce exactly from one run to the next. Everything past this one step (the API key, the full `--scope` syntax, output modes, exit-code gating) lives in [the CI/CD database seeding docs](/docs/cicd-database-seeding). ## The GitLab CI equivalent The same job on GitLab CI looks similar and behaves differently in two ways worth knowing before you port it across. ```yaml test: stage: test image: node:22 services: - postgres:16 variables: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: app DATABASE_URL: postgres://app:app@postgres:5432/app before_script: - apt-get update -qq && apt-get install -y -qq postgresql-client - until pg_isready -h postgres -U app; do echo "waiting for postgres"; sleep 1; done script: - npm ci - npm run migrate - npx seedfast seed --scope "customers with a few orders and line items each" --output plain # env: SEEDFAST_API_KEY, SEEDFAST_DSN=postgres://app:app@postgres:5432/app - npm test ``` The first difference is the hostname. On GitLab's Docker executor — the default for gitlab.com shared runners — the job container and the `postgres` service share a network, and the job reaches the database at the service alias `postgres`, never `localhost`. That's the mirror image of the GitHub Actions runner-host default, which uses `localhost`, so assuming one platform's convention holds on the other is the classic cross-platform miss. A self-hosted runner on the shell executor runs the service on the same host, where `localhost` is correct instead, so check which executor a runner uses first. GitLab's own [PostgreSQL service docs](https://docs.gitlab.com/ee/ci/services/postgres/) lay out the alias behavior. The second is readiness. GitLab's `services:` keyword has no health-check field like Actions' `--health-cmd`; GitLab Runner confirms the service's port is open, not that Postgres has finished recovery and is taking real connections, so a job that assumes readiness the instant the port answers can still hit "connection refused". The `pg_isready` loop in `before_script` closes that gap by hand. One more base-image difference hides in there. The `node:22` image ships neither `pg_isready` nor `psql`, so `before_script` installs `postgresql-client` first, which GitHub's `ubuntu-latest` bundles by default. The Seedfast step itself is identical to the Actions one, `SEEDFAST_DSN` pointed at `postgres:5432` this time; the GitLab secrets setup lives in [the same doc](/docs/cicd-database-seeding). ## Getting deterministic data into a mostly-generated pipeline Generated data invites a fair objection: if the rows differ every run, won't the tests flake? They will, if you assert on exact values a generator is free to change between runs. The fix isn't to make the generator deterministic, since most schema-aware generators, Seedfast included, don't promise bit-for-bit repeatable output, while [drizzle-seed](/compare/drizzle-seed-alternative)'s seedable RNG is the tool for that when full repeatability is the real requirement. The fix is to split the data. Pin the handful of rows a test genuinely depends on (the admin user it logs in as, the feature flag it checks, the plan tier it bills against) in a short SQL file, and run it before Seedfast so those values are always present. Then let the generator fill the volume around them, the hundreds of rows whose contents no assertion cares about. ```sql -- reference.sql — checked into the repo, runs before the generator INSERT INTO feature_flags (key, enabled) VALUES ('checkout_v2', true) ON CONFLICT (key) DO NOTHING; INSERT INTO users (email, role) VALUES ('admin@example.com', 'admin') ON CONFLICT (email) DO NOTHING; ``` ```bash psql "$DATABASE_URL" -f reference.sql npx seedfast seed --scope "50 customers with varied order histories" --output plain ``` The `ON CONFLICT ... DO NOTHING` guards keep the file idempotent, so a re-run never trips over rows it already inserted. This split, fixed anchors and generated bulk, is the same pattern [seeding a Supabase database](/blog/supabase-db-seed) uses, applied here to the CI-flakiness question, so that tests read the pinned rows while the generated volume only has to be valid, not predictable. ## Frequently asked questions ### Do I connect to a GitHub Actions Postgres service over localhost or the service name? It depends on where your steps run. A job with no `container:` key runs on the runner VM, so the service is reachable at `localhost:5432` and you publish it with a `ports:` mapping. Add a `container:` and the steps run inside it on the same Docker network as the service, where you connect to the service label (`postgres:5432`) and the `ports:` mapping becomes unnecessary — publishing a host port only matters when something on the host, not a sibling container, needs the database. Mixing the two, a `ports:` mapping plus a service-name hostname, is a common source of intermittent connection failures. ### Why does my GitLab CI job fail to connect to Postgres at localhost? Because on GitLab's Docker executor the service doesn't live on the job's `localhost`. The job runs in its own container, the `postgres` service runs in a separate linked container, and GitLab exposes it under an image-derived alias (`postgres` for the `postgres:16` image), so `localhost` points at the job container itself, which holds no database. Switch the host to `postgres` and it resolves. The exception is a self-hosted shell-executor runner, where the service and job share the host machine instead of sitting in separate containers. ### Does GitLab CI wait for Postgres to be ready before my job runs? Only partly. GitLab Runner waits for the service's exposed port to accept a TCP connection before it starts your `script`, but an open port isn't a database that has finished crash recovery and is answering queries, so a fast first step can still race it. There's no `--health-cmd` equivalent on GitLab's `services:` keyword, which is why the reliable pattern adds a `pg_isready` retry loop in `before_script`. The quick-start example runs fine most of the time without it; add the loop when jobs start failing intermittently under load. ### Can CI test data be both generated and deterministic? Partly, and the split is the whole trick. Pin the rows your assertions read (a known admin account, a fixed set of feature flags) in a small checked-in SQL file, and let a generator produce the surrounding volume. The pinned rows are deterministic because you wrote them; the generated bulk generally isn't, and a schema-aware generator like Seedfast doesn't claim otherwise. If your tests truly need every generated row reproducible from run to run, that's a different requirement, and a seedable generator such as drizzle-seed (a fixed `seed` value reproduces the same rows) is the more direct fit than pairing a reference file with non-deterministic generation. ## The GitHub Actions seed Postgres database checklist Everything above reduces to one running order. Bring up the service container, gate it on `pg_isready`, migrate, then seed the moment before the tests read the database. Which method you pick decides how much you maintain afterward, whether that's a dump you regenerate or a script you patch. [Seedfast](https://seedfa.st/) is the third shape, re-reading the schema between migrations and dropping in as the single seed step above, and the [CI/CD seeding doc](/docs/cicd-database-seeding) picks up the rest. The same health-gated seed step runs locally under [Docker Compose](/blog/docker-compose-seed-database) when you want the loop off the pipeline. Off YAML entirely, [Claude Code drives the identical seed over MCP](/blog/claude-code-mcp-database-seeding), which moves the scope prompt into the editor and keeps the pipeline out of it. And when the test runner reading that seeded database is pytest, [the pytest database fixtures guide](/blog/pytest-database-fixtures) covers the scope and transactional-rollback layer that sits on top of the seed step. --- # Synthetic Test Data: The Values Were Never the Hard Part > Source: https://seedfa.st/blog/synthetic-test-data-generation > Last updated: 2026-09-10 Synthetic test data is data a generator invents from a schema or a written description instead of copying it out of production, which is why no row in it traces back to a real person or a real transaction. For a relational database that is only half the requirement. A row whose values all read perfectly still gets rejected on insert when its foreign key points at a parent that was never written, and the load halts. Synthetic test data generation has to handle the values and the connections between them in the same pass. > **TL;DR:** Synthetic test data generation splits into two problems of very unequal difficulty. Producing believable column values like names and amounts is the easy part, handled well by faker libraries, distribution samplers, and language models alike. Producing rows that satisfy the schema's foreign keys and constraints, and keep satisfying them after the next migration, is the part that separates usable data from data that merely looks right. ## Key Takeaways - Value generation and structural validity are separate problems; most tools solve the first well and quietly hand you the second - Four approaches make up the generation spectrum: rule-based value synthesis, distribution-aware generation, LLM-based generation, and schema-aware generation, differing mainly in how much of the schema they actually read - Even data that inserts cleanly can hide the bugs that only production-shaped skew surfaces, like a query plan that flips once a column stops being uniform - Schema drift is the quiet failure, the data generated against last month's schema or by a generator whose column config predates the last migration - Where you run generation changes what you need from it, since a quick local reseed asks far less than an unattended CI step that has to fail loudly - Seedfast reads the live schema at the start of every run, so the rows it writes match the tables as they stand rather than as a cached column mapping remembers them --- ## The synthetic test data generation spectrum Every method of generating synthetic test data answers one question (where do the values come from) and quietly ducks a second: does anything make the rows connect? Sorting the approaches by how they handle both is more useful than ranking them, because each is genuinely the right tool somewhere. **Rule-based value synthesis** is the floor, and the usual first stop. A library like Faker maps each column to its own provider and calls it once per field, independently. Run two loops, one for users and one for orders, and you get tables that each look right on their own: ```js // Two independent Faker passes: every value is plausible, the link is fiction const users = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, email: faker.internet.email(), })); const orders = Array.from({ length: 20 }, () => ({ id: faker.string.uuid(), user_id: faker.number.int({ min: 1, max: 1000 }), // references nothing in particular total: faker.commerce.price(), })); ``` Every `user_id` is a well-formed integer. Almost none of them point at one of the five users that exist. Nothing looks wrong until a real `REFERENCES users(id)` stands between the row and the table, at which point the first orphaned insert aborts and the seed script dies half-finished. Faker did exactly what it was asked to do — generate values — and never saw the question that actually mattered. **Distribution-aware generation** keeps the field-at-a-time model and fixes what the values look like in aggregate. Rather than spreading an `amount` column evenly across a range, it samples from a shape that resembles real money, a long tail of small values beneath a few large ones, and clusters timestamps the way real activity clusters. This matters because your database reads those shapes. The planner picks a different plan against skewed data than against a uniform smear, and any code that ranks or thresholds behaves differently too. For the specific distributions and why they bite, see [realistic test data](/blog/realistic-test-data). **LLM-based generation** raises the ceiling on individual values. Ask a model for a product description and it writes one that fits the product rather than lorem ipsum; ask for a support-ticket body and it reads like a person typed it. Semantic coherence inside a single value is what language models are unusually good at. Their limit is memory. A model works only from the schema you paste in, and a wide one with all its keys and constraints stops fitting in that window, so by the time it reaches the tables deep in the dependency chain it has lost track of what it set up at the top. The values come out excellent and the structure drifts, which is why the durable pattern keeps the model on values and hands the connecting work to deterministic code. For which AI-based tool does that best, the [best AI test data generator](/blog/best-ai-test-data-generator) comparison ranks them. **Schema-aware generation** answers the second question head-on. Before it produces a value, the generator reads the target schema (the tables, the column types, the foreign keys) and writes rows that fit those relationships by construction rather than by luck. The values can still come from any approach above, a language model included; what changes is that the structure is read from the live schema instead of assumed. It is the one point on the spectrum where a child row is guaranteed to reference a parent that exists, because the generator knew the reference was there before writing the row. Seedfast works this way, taking a connection string and reading the catalog behind it before it invents a single value. Line the four up and the shape of the problem is hard to miss. Generating values — the plausible names, the believable amounts — is the easy 80% of the work, and every approach handles it competently. The other 20%, making a row satisfy the schema's foreign keys and constraints so the database accepts it on insert, is what separates data you can test against from data that only survives in a CSV. A generator that produces flawless values and orphaned references hasn't done the hard part. It skipped it. ## Where synthetic test data fails in practice The failures cluster into four kinds, and a project tends to meet them in that order. **Broken references** are the first and loudest failure. A row points at a parent that was never inserted, the constraint rejects it, and the whole load halts. It is the orphaned-key problem from the Faker snippet scaled to a real schema, and past a handful of related tables, keeping every reference valid by hand stops being something a person does reliably. The database has a name for what it is enforcing, [referential integrity](/blog/referential-integrity), and clearing it is the floor beneath everything else a generator claims to do. Flatter and quieter is the distribution failure, where the data inserts fine and the suite stays green. Uniformly random values give a column a shape no real column has, and that shape leaks downstream. The planner reads its statistics off it and picks a plan it would never choose against skewed production data, while a load test on the same flat spread exercises none of the paths real skew would light up. Small uniform datasets are where [the bugs that only appear once the data has real volume and real shape](/blog/small-data-big-lies) go to hide. The third failure arrives with elapsed time. Schema drift is data generated against last month's schema (or by a generator whose column mapping predates the last migration), and it is wrong in the way that is hardest to spot: a `NOT NULL` column lands on Tuesday, and by Thursday every seed either errors outright or quietly fills that column with a value that means nothing. A generator that reads the schema once and caches it carries this risk for good; one that reads it fresh on every run never has it. That leaves volume, the mildest miss and still a routine one. Ten rows prove the code runs and say nothing about how it behaves once pagination turns slow or an unindexed query drifts toward a timeout. More rows alone will not fix it, though, since they have to carry the right shape to be worth anything, which lands right back on the distribution problem a couple of paragraphs up. ## Where generation runs in a real workflow The same generator gets pointed at very different jobs, and each one asks something slightly different of it. The tightest loop is local development. You have changed a model, you want a database that reflects it, and you want it now. Speed matters more than polish here; the data can be rough as long as it is valid. One step out is standing up a fresh environment, a new clone or a preview database spun up for a pull request. Both start empty and want a believable dataset fast, without a copy of production and the questions that come with it. A [staging environment filled with generated data instead of a masked dump](/blog/staging-without-prod-data) sits outside the production-access conversation entirely, which is often the real reason a team generates rather than copies. Furthest out is CI, where the generator stops being interactive and has to run unattended on every push, fail loudly when it fails, and cost the same whether it fires once a day or eighty times across a job matrix. [Synthetic data for CI/CD](/blog/synthetic-data-ci-cd) is its own topic with its own constraints. Across all three, the through-line is simple. The further from your keyboard the generation runs, the less you are around when it breaks. Seedfast is built for that distance, reading the current schema and writing valid rows with nobody supervising, then exiting non-zero when something in the run fails. ## Generating synthetic test data with Seedfast [Seedfast](https://seedfa.st/) is the schema-aware end of that spectrum, packaged as a tool you run. Point it at a live PostgreSQL database and describe the data you want in plain English; it reads the current schema and writes valid, connected rows that fit it: ```bash seedfast seed --scope "500 customers with orders, payments, and a realistic spread of account ages" ``` ![A synthetic test data generation run in the Seedfast CLI: the seed command above returns JSON reporting 5,345 rows across six tables, all succeeded — 5 tenants, 40 products, and 500 users fanning out to 1,200 orders, 1,200 payments, and 2,400 order items.](/blog/synthetic-test-data-generation-1.png) That run answers the spectrum's second question on one screen. The row counts fan out the way a real schema demands, five hundred users carrying twelve hundred orders and twice that many order items, every one of them resolving. There is no seed file to maintain and no column mapping to keep current, because the schema read happens every run. Add a table in next week's migration and the following seed picks it up with nothing to reconfigure. The scope is a sentence, so the same command seeds a handful of rows for a unit test or a hundred thousand for a load test depending only on how you phrase it, not on any code you rewrite. Because the rows are invented from the schema and never sampled from production, no real customer record is involved at any point, which is what lets a generated [staging or preview environment](/blog/staging-without-prod-data) stay clear of the masking and PII questions a production copy drags along. That is a side effect of generating rather than copying, not a compliance product in its own right. The free plan takes no card and refills every month; tables and seeds stay uncapped on every tier, paid ones included. [Run your first seed](/docs/seed-your-database) takes a couple of minutes, or read the [pricing](/pricing) first. ## Frequently asked questions ### Does synthetic test data generation require an AI model? No. The oldest form of it, rule-based value synthesis of the kind Faker does, predates modern language models and produces correctly typed values with no model in the loop. A model earns its place on one narrow axis, the semantic realism of an individual value, so a product description matches its category instead of reading as filler. The structural side stays deterministic either way. If your real question is which AI-based tool does this best, the [best AI test data generator](/blog/best-ai-test-data-generator) comparison sorts them out. ### How do you generate synthetic test data that keeps foreign keys valid? Use a generator that reads the schema's relationships before it writes any rows, not one that fills columns in isolation and hopes the numbers line up. What catches people is a table that references itself, or two tables that reference each other, because there is no plain parent-first order for those; a generator has to write one side with the link left empty and fill it in a second pass, or lean on a constraint the database checks only at commit. Faker and web-based column tools leave that to you. Seedfast reads the relationships from the live database before it writes anything, so the rows that come out have every reference resolving, self-referencing tables included where the schema leaves a nullable side to work with. The property being protected is [referential integrity](/blog/referential-integrity). ### Can you generate synthetic test data without a live database connection? Yes, by feeding the generator a static description of the schema instead: a `schema.sql`, a set of migration files, or an ORM's model definitions. That helps in a locked-down sandbox where nothing reaches the database directly. The tradeoff is drift. A static file is only as current as the last export, so anything generated from it inherits whatever the file has fallen behind on. Reading the live catalog at run time removes that gap, which is why connection-based generation is the default when the data has to match the tables as they stand now. ### Is synthetic test data generation deterministic? Partly, and the deterministic half is the structure. A schema-aware generator produces the same shape of valid, connected rows on every run because the schema fixes that; the values riding on top are another matter. Rule-based libraries can reproduce a run exactly when you fix their random seed (Faker and drizzle-seed both expose one), while model-generated values make no byte-identical promise at all. Treat exact-value repeatability as a separate requirement from structural validity, and check which of the two your tests actually need before you shop for it. ## Judging a generator before you trust it Whatever approach you reach for, try it on a schema that has real foreign keys, not the single flat table the demos lean on. Two tools can produce output that looks equally clean and behave nothing alike the moment a child row has to resolve to a parent, where one completes the load and the other stops on the first orphan. Watching which one happens tells you, faster than any feature list will, whether a generator does the whole job or only the easy part of it. ## Related guides - [Test Data Generation: 7 Ways to Fill Your Database](/blog/test-data-generation) — the methods hub, where synthetic generation sits next to SQL files, factories, dumps, and masking - [Best AI Test Data Generator](/blog/best-ai-test-data-generator) — the tool-shopping counterpart, for picking a product once you have chosen the approach - [Synthetic Data for CI/CD](/blog/synthetic-data-ci-cd) — running generation unattended inside the pipeline, with exit codes and per-run cost - [Realistic Test Data: Beyond Random Values](/blog/realistic-test-data) — the deep version of the distribution and coherence problem this page only samples - [Referential Integrity](/blog/referential-integrity) — the constraint under "broken references," explained on its own terms - [Get started with Seedfast](/docs/seed-your-database) — connect a PostgreSQL database and generate your first valid, connected dataset --- # Give Google Antigravity's Agents a Seeded Postgres > Source: https://seedfa.st/blog/antigravity-mcp-database-seeding > Last updated: 2026-08-19 Google Antigravity's Manager Surface lets you spawn several agents at once and watch them work in parallel, one untangling a checkout bug, another chasing a flaky test, two more off on unrelated tickets. The friction shows the instant any of them reaches for data, because every agent you launched points at the same dev database and that database is empty, so the parallel runs stall together on rows nobody generated. Antigravity MCP Postgres seeding clears that before the first agent moves. Seedfast registers once in the config the IDE and the CLI both read, and from there one prompt fills the schema with connected rows while the agents are still spinning up. Antigravity ships real database tooling, yet every one of those tools reads what a schema already holds; none fills a schema holding nothing. Seedfast does that second job, working from the schema itself to put connected rows into every table under it, and the wiring is what this page covers. ## What Google Antigravity is now, and what became of Gemini CLI Google Antigravity launched as an agentic IDE in public preview in November 2025, then widened at Google I/O on May 19, 2026, when Antigravity 2.0 arrived as an agent-first platform with a standalone Antigravity CLI beside the desktop app ([Google's announcement](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/), [TechCrunch's coverage](https://techcrunch.com/2026/05/19/google-launches-antigravity-2-0-with-an-updated-desktop-app-and-cli-tool-at-io-2026/)). If you were leaning on Gemini CLI, that is where it went, retired on June 18, 2026 for Pro, Ultra, and free users, with only Enterprise keeping access. The move ran closer to a rename than a rewrite, the old `GEMINI_API_KEY` becoming `AV_API_KEY` while the workflow carried over intact ([the transition post](https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/)). What keeps the setup below short is that both surfaces read the same Model Context Protocol configuration. They load `~/.gemini/config/mcp_config.json`, one user-level file whose `mcpServers` block covers every surface at once, with no project-scoped MCP file to juggle the way Claude Code keeps a `.mcp.json` per repo or Codex a `config.toml`. Register a server once and it answers from both surfaces, so command-line seeding, once Gemini CLI's job, now runs through the Antigravity CLI. One caveat on that path, which has already moved once. Pre-2.0 guides point at `~/.gemini/antigravity/mcp_config.json` rather than the `config/` location the 2.0 docs use, which is why an older walkthrough may send you somewhere that looks wrong. ## Set up Antigravity MCP Postgres in mcp_config.json Two things have to exist first, a Seedfast account and an API key from the dashboard, which arrives in the `sfk_live_...` shape. Because `mcp_config.json` is a user-level file, not something checked into a repo, the key stays in your home directory rather than traveling with a project, which sidesteps the commit-it-safely question the repo-scoped clients have to answer. Open `~/.gemini/config/mcp_config.json` and add Seedfast under `mcpServers`: ```json { "mcpServers": { "seedfast": { "command": "npx", "args": ["-y", "seedfast@2.6.0", "mcp"], "env": { "SEEDFAST_API_KEY": "sfk_live_your_api_key_here" } } } } ``` The entry is the standard command/args/env shape, and Antigravity runs local stdio servers over `npx`, so Node 18 or newer is the only prerequisite. Note the pinned version rather than `@latest`: the tag re-resolves every time the server starts, and we publish often enough that leaving it open means the build under you can change without the config changing. `npm view seedfast version` tells you what to move the pin to. Restart Antigravity or reload the window, and the server comes up for the IDE and the CLI together. To check the wiring, ask the agent to call `seedfast_doctor`, which reports whether the CLI is healthy and your key authenticated. Antigravity also carries an in-app screen for adding and toggling MCP servers, and since that menu route has shifted between doc versions, treat the JSON file above as the anchor and let Google Antigravity's own MCP docs hold the current click-path. ![Seedfast registered in Antigravity's mcp_config.json alongside Google's Cloud SQL server, one filling the database and one reading it](/blog/antigravity-mcp-database-seeding-1.png) Two more tools ride alongside `seedfast_doctor`, `seedfast_plan` and `seedfast_run`, and the [MCP setup guide](/docs/mcp-setup-guide) covers what each returns and how long a run takes. ## One prompt, and the schema fills With the server registered, seeding reads like a request rather than a task. Say a screen you are building needs organizations that own a few users and projects. You would type something close to this into the agent panel: ``` Seed a handful of organizations, each with a few users and a couple of projects, using seedfast_run ``` Antigravity hands that scope to `seedfast_run`, and Seedfast takes the relational side from there, reading the schema as it stands at that moment and writing rows where every user and project belongs to an organization that exists, every value made up new. Scope sets scale as much as shape, so "a handful" keeps a unit test quick while "a few thousand users" gives a query planner something heavier to chew on. ![Antigravity agent panel running a seed prompt with the seedfast_run summary of tables and rows](/blog/antigravity-mcp-database-seeding-2.png) When it finishes, ask for the summary and the agent reports which tables were touched and how many rows landed in each, naming anything that failed; a plain `SELECT count(*)` reads the same numbers from the database side. The same prompt works from the Antigravity CLI unchanged, since terminal and IDE resolve the same `mcp_config.json`, so a seed first written at your desk repeats inside an SSH session or a throwaway container off that one line. ## Where Antigravity's native database tools stop Google Antigravity ships database tooling of its own, worth being precise about so you know where Seedfast fits. The built-in MCP Store offers one-click installs for Google-native servers, and the AlloyDB and Cloud SQL connector is the relevant one. Once installed, it exposes `list_tables`, `get_table_schema`, `execute_sql`, and `get_query_plan` ([per the Google Cloud blog](https://cloud.google.com/blog/products/data-analytics/connect-google-antigravity-ide-to-googles-data-cloud-services)), a capable kit for administering and interrogating a database from inside the agent. Read that list closely and the boundary draws itself. Every one of those tools acts on data already present, listing what exists, describing a table's shape, running a query, explaining a plan. Not one puts a row into an empty table. That is the seam this page has circled, the native tools answering what is in this schema while Seedfast answers the other half, the schema is empty, fill it with connected rows. Both sit in the same `mcp_config.json`, the Store server for reading and querying, Seedfast for the population step the Google-native tools skip. ## Tool Approval, and why the target database still matters By default Antigravity executes MCP tool calls without pausing for approval, so a `seedfast_run` fires the moment the agent decides to call it. Turning on Tool Approval, under Settings → Antigravity → MCP → Tool Approval, makes those calls wait for your yes, while terminal commands answer to a separate allow-list, so the two are governed independently. The exact menu labels have shifted between releases, so treat them as directional and confirm in-app. Gate the calls or not, the durable habit is the connection string, which should belong to a dev or branch database and never to production. Seedfast makes that easy to keep, since it never needs production data to work, reading only the schema and writing fresh rows, so nothing real comes within reach of the run. There is a reason beyond safety, too. Google Antigravity drew rate-limit and throttling complaints through spring 2026, so a dev database is where a stalled or retried run costs nothing. ## When the built-in tools are enough, and when they aren't None of this argues for reaching past the native tools every time. When a database already carries data and you want to inspect it, run a query, or read a plan, the Store's AlloyDB and Cloud SQL server covers the job on its own, and installing a generator you won't call is clutter. The honest scope for Seedfast is the empty schema, the migration that ran against nothing, the branch cloned thin, the fresh container, the moment the tables exist and hold none of the rows a test needs. That part is what the native tools structurally cannot do. If your seat is elsewhere, the same server block moves with almost no change. Claude Code takes it in a [project-scoped `.mcp.json`](/blog/claude-code-mcp-database-seeding) and Codex CLI in a [`config.toml`](/blog/codex-cli-database-seeding), same block, different client. The case for generating this data rather than handing an agent a [copy of production](/blog/ai-agent-production-database) is its own argument, as is the reason [raw model-written SQL](/blog/generate-test-data-with-ai) falls apart on the relationships between tables, both worth reading before you wire an agent to a database it can write to, alongside a wider look at [which MCP servers actually generate test data](/blog/mcp-test-data) versus the ones that just move files around. ## Frequently asked questions ### Does Google Antigravity support MCP servers like Seedfast? Google Antigravity supports MCP servers natively in both the IDE and the CLI, which read them from one shared `~/.gemini/config/mcp_config.json`. Google's own servers install from the built-in catalog in a click, while a third-party server like Seedfast goes in by hand as a `command`/`args`/`env` entry under `mcpServers`, after which the agent can call its tools from either surface. ### What happened to Gemini CLI, and does the migration change MCP config? Gemini CLI was retired on June 18, 2026 for Pro, Ultra, and free users and folded into the Antigravity CLI, with only the Enterprise tier keeping the old tool. The migration ran close to a rename, its main environment change being `GEMINI_API_KEY` giving way to `AV_API_KEY`. It leaves MCP registration alone, because the Antigravity CLI reads the same `mcp_config.json` the IDE does, so a Seedfast entry set up for one already works for the other. ### Does Antigravity's AlloyDB MCP server seed a database, or only query it? Antigravity's AlloyDB and Cloud SQL MCP server queries and administers a database rather than seeding one. Its tools, `list_tables`, `get_table_schema`, `execute_sql`, and `get_query_plan`, all operate on data that already exists, so an empty schema stays empty however many of them the agent calls. Seedfast covers the other half, reading the same schema and writing the connected rows those tools have no way to create, which is why it sits beside the Google-native server rather than in place of it. ### Do Antigravity MCP tool calls require approval before they run? Antigravity MCP tool calls run without approval by default, executing the moment the agent invokes them. Switching on Tool Approval in the MCP settings holds each call until you confirm it, while terminal commands answer to their own allow-list. For a seed in particular, the setting that earns more than the approval gate is the connection string, kept pointed at a disposable database so an unattended call has nothing valuable to reach. ## Data in place before the agents spread out The parallel-agents picture from the top is really an argument for doing the data step first, and once. When the agents meet a schema that already holds organizations, users, and projects that hang together, none of them burns a run improvising fixtures or going green against nothing, and what you read back at review reflects code rather than a gap in the setup. Seedfast is the piece that puts that data there, and the [free plan](/pricing) is room enough to drop in the one config block and watch a schema fill with connected rows before the agents you spawned ever notice it was empty. --- # Give GitHub Copilot's Coding Agent a Seeded Postgres > Source: https://seedfa.st/blog/copilot-coding-agent-database-seeding > Last updated: 2026-08-19 You assign an issue to Copilot, and a few minutes later a pull request is waiting for review with its checks already red. The migrations ran. What broke is everything downstream of them, the tests that expected a customer to exist, an order to join against, a row to assert on, all firing against a schema with nothing inside it. Copilot coding agent database seeding is the fix, and it comes down to one file, `.github/workflows/copilot-setup-steps.yml`, where Seedfast reads the schema your migrations just created and fills it before the agent writes a line. GitHub [renamed Copilot coding agent to Copilot cloud agent](https://github.blog/changelog/2026-04-01-research-plan-and-code-with-copilot-cloud-agent/) on April 1, 2026, and both names are live across its docs right now, so I'll stay with "coding agent" throughout. Why you'd hand that agent freshly generated data instead of a clone of production is [its own subject](/blog/ai-agent-production-database); here I'm taking that decision as made and wiring the environment. ## Where a coding agent task actually runs Every task Copilot picks up runs in a throwaway GitHub Actions environment provisioned for that one job, on a single branch that becomes a single pull request. Nothing persists between tasks. When the run ends the environment is gone, so the database behind it has to be built from scratch each time rather than assumed from an earlier session. Seedfast rebuilds the data half of that in one step, reading whatever schema the migrations left behind and writing connected rows into it, which is why the same file works on a task you assign next month against a schema three migrations further along. The setup phase you control tops out at 59 minutes, and otherwise the environment behaves like the Actions jobs you already write, down to the service-container mechanics, with one twist in the secrets model. ## The copilot-setup-steps.yml file, and the line that breaks it silently The configuration lives at `.github/workflows/copilot-setup-steps.yml`, and it reads like an ordinary workflow, except only one job inside it is read, the one named `copilot-setup-steps`. Name it anything else and GitHub ignores the file with no error and no warning, and the agent starts against an empty database as if you'd never written it. [GitHub's environment customization docs](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/customize-the-agent-environment) list the honored keys, and the set is small. You get `steps`, `permissions`, `runs-on`, `services`, `snapshot`, and `timeout-minutes`, and anything outside that set is quietly dropped. `snapshot` is listed as honored, but the docs don't describe what it does, so I'll leave it in the set without guessing at its behavior. The job runs before the agent starts, and it also fires on `workflow_dispatch` and on any push or pull request touching the file itself, which lets you prove the setup is green without waiting on a real task. One requirement is easy to miss. The file has to live on your default branch to take effect at all; a copy on a feature branch does nothing. ## A setup file that migrates and seeds Postgres Here's a minimal, runnable version that stands up a PostgreSQL service, runs your migrations against it, and seeds it with Seedfast. ```yaml name: Copilot setup steps on: workflow_dispatch: push: paths: - .github/workflows/copilot-setup-steps.yml jobs: copilot-setup-steps: runs-on: ubuntu-latest timeout-minutes: 20 services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: app ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Run migrations run: npm run migrate env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/app - name: Seed with Seedfast run: npx seedfast seed --scope "20 customers with orders and line items" --output json env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/app SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} ``` The `services` block brings up a `postgres:16` container with a health check that holds the run until the database answers, the same pattern any Actions job uses, so nothing here is Copilot-specific until the secret. Migrations run first against `localhost:5432`, then the seed step calls `npx seedfast seed` (npx ships with npm) with a plain-language scope and `--output json` for a machine-readable result. Seedfast reads the schema off that connection string and composes fresh rows whose relationships resolve, inventing every value rather than copying anything out of a real database, the generate-per-run approach behind [synthetic data in CI/CD](/blog/synthetic-data-ci-cd). Exit codes and the rest of the pipeline-side mechanics live in the [CI/CD seeding docs](/docs/cicd-database-seeding). ![GitHub Actions run of the copilot-setup-steps job succeeding, with the Seedfast CLI installed and reporting ready](/blog/copilot-coding-agent-database-seeding-1.png) If you already seed this schema locally with a Compose stack, the file above is the CI cousin of that [docker-compose seeding workflow](/blog/docker-compose-seed-database), except this container is discarded the moment the task ends. One gap is worth flagging. Whether that `postgres:16` service stays reachable at `localhost:5432` once the agent later runs your test command itself, rather than during setup, isn't something the docs spell out, so treat the service as guaranteed for the setup steps and verify it for anything the agent runs afterward. ## Why the agent can't see your Actions secrets A working setup file can still fail at the secret. The agent doesn't read your repository's Actions secrets, nor the Codespaces or Dependabot ones; it reads only the secrets you add under a separate store, at Settings → Secrets and variables → **Copilot** (the tab GitHub's [secrets and variables guide](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/configure-secrets-and-variables) refers to as Agents). A team whose `SEEDFAST_API_KEY` has always lived in Actions secrets watches the seed step throw an auth error that has nothing to do with the key being wrong, because from the agent's environment that value isn't there. ![SEEDFAST_API_KEY configured as a Copilot secret in repository settings](/blog/copilot-coding-agent-database-seeding-2.png) Whatever you put in that tab arrives in `copilot-setup-steps.yml` as an environment variable, which is what the seed step expects from `${{ secrets.SEEDFAST_API_KEY }}`. One naming detail matters on the MCP route covered next. A secret consumed by an MCP server carries the `COPILOT_MCP_` prefix, so the same value becomes `COPILOT_MCP_SEEDFAST_API_KEY` there, while the setup-steps file needs none. ## The firewall that doesn't touch your setup steps Copilot's coding agent runs behind a firewall that's on by default, and a blocked outbound call shows up as a warning on the pull request. What matters for seeding is the firewall's scope. Per [GitHub's firewall documentation](https://docs.github.com/copilot/customizing-copilot/customizing-or-disabling-the-firewall-for-copilot-coding-agent), it "only applies to processes started by the agent via its Bash tool. It does not apply to Model Context Protocol (MCP) servers or processes started in configured Copilot setup steps." So the seed step above never meets the firewall, and you allowlist nothing for it to reach the Seedfast API. The firewall only bites when the agent itself runs the seed mid-session, calling `npx seedfast seed` through its Bash tool. For that case, add `seedfa.st` to the allowlist under Settings → Copilot → coding agent → Custom allowlist, and the call goes through. MCP is the other way to give the agent a seed tool it can call by name, sitting on the safe side of the firewall just as setup steps do. You configure it in the repository under Settings → Code & automation → Copilot → MCP servers, pasting a JSON block rather than committing a file. Local `npx` servers are supported, using `"type": "local"` with `"command": "npx"`, and any secret the block references takes the `COPILOT_MCP_` prefix. GitHub's [MCP extension guide](https://docs.github.com/copilot/how-tos/agents/copilot-coding-agent/extending-copilot-coding-agent-with-mcp) carries the full schema. It's the same `npx`-launched Seedfast server the terminal agents register, parked in settings instead of a config file, and it fits when you want the agent seeding on demand mid-task rather than once up front. ## When this isn't worth setting up Not every task needs any of this. When the work never runs data-dependent code, a docs fix, a refactor nowhere near the test path, a copy change, a seeded Postgres buys you nothing and the file is dead weight on your default branch. Add it when your suite genuinely hits the database on the branches Copilot works, and skip it otherwise. There's a second cost worth naming. The Agents secrets store, the firewall allowlist, and the MCP JSON all live in repository or organization settings, so standing them up needs repo-admin, more ceremony than the terminal agents ask for. [Claude Code](/blog/claude-code-mcp-database-seeding) reads one `.mcp.json` at the repo root, [Codex CLI](/blog/codex-cli-database-seeding) takes one `codex mcp add` into a `config.toml`, both keeping the arrangement in files a developer owns without opening GitHub settings. A team already seeding through one of those in their editor may not want a second, settings-level setup just for Copilot's cloud runs, and that's a fair call. ## Frequently asked questions ### What must the copilot-setup-steps.yml job be called? The job in `copilot-setup-steps.yml` must be named `copilot-setup-steps` exactly, or GitHub skips the file. Nothing warns you when the name is wrong; the agent just starts without your setup having run, a common reason a database that should have been populated turns up empty. It also has to be committed to the default branch before it takes effect. ### Can the GitHub Copilot coding agent use a Postgres service container? Yes. The GitHub Copilot coding agent honors a `services:` block in `copilot-setup-steps.yml`, so a `postgres:16` service container starts exactly as it would in any GitHub Actions job. Attach a health check so the setup waits until the database accepts connections, then run your migrations and a Seedfast seed as ordinary steps against `localhost:5432`. ### Why doesn't my GitHub Actions secret work in copilot-setup-steps.yml? A GitHub Actions secret doesn't reach the coding agent because the agent reads a different store. Only secrets under the Copilot tab of Settings → Secrets and variables (the store GitHub's docs call Agents) are exposed to `copilot-setup-steps.yml`; the Actions, Codespaces, and Dependabot stores stay invisible to it. Move the value into that tab and the reference in your YAML resolves. ### Does the Copilot coding agent's firewall block calls to the Seedfast API? Not from your setup steps. The firewall is scoped to processes the agent launches through its Bash tool, so seeding that runs inside `copilot-setup-steps.yml` or through an MCP server reaches the Seedfast API untouched. Only when the agent runs `npx seedfast seed` itself, mid-task, does the firewall apply, and adding `seedfa.st` to the coding-agent allowlist clears that path. ## Data behind the code, before review The payoff shows up at review time. When Copilot's pull request lands, its checks have run against generated rows with valid relationships, so red on the branch points at code the agent got wrong rather than fixtures nobody remembered to load. Seedfast keys come from the dashboard, and the [free plan](/pricing) is enough to wire this into a repo and watch a task come back green off a database it built itself. Put the file in place once, and it stays a thing you set up rather than a thing you maintain, every task Copilot takes on after that starting with data already behind it. --- # Faker Doesn't Know What Your Data Means > Source: https://seedfa.st/blog/realistic-test-data > Last updated: 2026-07-11 A single row of Faker output looks flawless. Call `faker.name()`, `faker.email()`, and `faker.date_time_this_decade()`, and back come `Gloria Hettinger`, `gloria.hettinger@example.org`, and a timestamp somewhere in 2023, each value drawn from a distribution that fits its column. Stack ten thousand rows like it and every column still survives inspection on its own. What none of those rows carry is a relationship to the row beside them, or to the row in the next table over. That gap is the whole subject here, because realistic test data usually gets treated as a property of columns (realistic names, well-formed emails) when the realism that actually matters lives in the joins between them. Faker is the convenient example rather than the target; the same blind spot shows up in any generator that fills one column at a time. ## Key Takeaways - Realistic test data is not a property of columns. A row of Faker output survives inspection value by value and still says nothing true about the row beside it or the table it references. - One axis of coherence runs inside a row, where the values have to agree with each other, and the other runs across the join, where a child row has to make sense against the parent it points at. - The axis people forget is distribution, since real transaction amounts follow Benford's law and real per-user activity is Zipfian, while a uniform column can steer the Postgres planner toward a plan production would never choose. - [Seedfast](https://seedfa.st/) reads your schema on every run and infers what each column is for, so the values it writes agree with one another inside a row and with the rows they reference. - A join across the tables Seedfast filled returns every row, because it never writes a foreign key pointing at a parent it hasn't created. ## What plausible hides Generate a `users` table and an `orders` table in two separate Faker loops and each table looks right in isolation. The `orders` table has a `user_id` column full of integers in a believable range, none of them null, all of them the correct type. Whether any of those integers points at a user you actually generated is a question Faker was never asked. ![Faker-generated users and orders rows where order user_id values match no generated user id](/blog/realistic-test-data-1.png) The screenshot above is the ordinary result: five users numbered 1 through 5, five orders pointing at user IDs in the hundreds, a child table that inserts cleanly only because no constraint stood in the way. Add a real `REFERENCES users(id)` and the same seed script dies on the first orphaned row instead. Seedfast produces the opposite artifact. Every `user_id` it writes into `orders` belongs to a user it also created, so the join across the two tables returns every row instead of quietly dropping the orphans. ![psql JOIN across Seedfast-seeded users and orders showing every foreign key resolving](/blog/realistic-test-data-2.png) Getting that join to resolve completely is the floor, and clearing it proves almost nothing on its own. Everything the rest of this article covers sits above that line. ## Faker generates one column at a time, on purpose This isn't a defect in Faker, it's the shape of the API. Each provider call returns a value for one domain from a source of randomness that holds no state about the calls before it or the columns around it, which is exactly what makes the library fast, composable, and easy to reason about. As the community puts it, "Faker generates individual field values independently, with no concept of relationships." A `first_name` and a `last_name` in the same row have no idea they share a row, and an `email` two columns over won't contain either of them unless you wire that up by hand. For a great deal of work, that is the right tool. A unit test that needs one well-formed email, a single flat table with no foreign keys, a throwaway fixture you discard right after the assertion, none of these need cross-row reasoning, and reaching for something heavier only adds ceremony. Once an "AI" label gets stapled onto that same column-by-column approach, the shape underneath hasn't moved, and separating a genuinely schema-aware tool from a chat wrapper is exactly what the [best AI test data generator](/blog/best-ai-test-data-generator) comparison walks through. The trouble starts when the data has to hold together, not merely look right column by column. ## Where valid and real diverge Column values can each be individually plausible and still be collectively wrong, and the clearest case is numeric distribution. Faker will fill an `amount` column with values spread uniformly across a range, and uniform is precisely what money is not. Real transaction amounts, account balances, invoice totals, and most quantities that span several orders of magnitude follow Benford's law, their leading digit landing on 1 about 30 percent of the time and on 9 under 5 percent. Balances skew log-normal, a few large accounts sitting over a long tail of small ones. Per-user activity skews Zipfian, a handful of power users producing most of the rows while everyone else trails off. A uniform column reproduces none of these shapes. The distribution matters because your code and your database both read it. Postgres plans a query from the statistics it has gathered on a column, so a flat spread over ten thousand distinct values can steer the planner toward a sequential scan where production, with its skew, would reach for an index (or the reverse). A `SUM` over uniform amounts settles near a total that real data would never land on. Anything that ranks or thresholds, a top-accounts panel or a fraud rule that fires above a percentile, behaves against this data in a way it never will against the real thing. This failure is semantic rather than structural, the rows staying perfectly valid while meaning something the domain would never produce. ## A foreign key checks existence, not meaning Coherence has a second axis, one running across rows the schema connects rather than across columns in a single row. A foreign key enforces that the referenced row exists, and enforces nothing about whether the reference makes any sense. So an `orders` row can point at a genuine customer and still carry a `placed_at` earlier than that customer's `created_at`, an order placed by an account that didn't exist yet. A `subscriptions` row can reference a billing period that never ran. A `shipments` row can belong to an order nobody ever paid for. Each of these satisfies [referential integrity](/blog/referential-integrity) while being obvious nonsense, the sort a reviewer catches at a glance and a constraint check is built to ignore. This is how a suite stays green against seeded rows and still sails past the bug a coherent dataset would have exposed. Let your [E2E fixtures](/blog/e2e-test-fixtures) hold orders older than their own customers, and any test that should exercise an account-age or tenure calculation never meets a row that would break it. ## What realistic test data actually requires Put the two axes together and a working definition falls out. Realistic test data has to hold cross-column coherence inside each row, cross-row coherence across every table it connects to, and distributions shaped like the domain rather than like a random number generator. Meeting all three by hand means encoding the schema's insert order, its temporal rules, and its statistical profile into fixture code, then re-encoding them after every migration that moves any of it. That maintenance burden is why most [data seeding tools](/blog/data-seeding-tools) stop at column values and hand the relational and semantic work back to you. Seedfast closes the gap by reading the schema itself on every run, never a snapshot that drifts the moment someone ships an `ALTER TABLE`. It connects to your Postgres, reads what is there, and generates values inferred from each column's role, so a `placed_at` lands after the customer's `created_at` and an `amount` column comes out shaped like money instead of like `random()`. You describe the scenario in plain language and it fills the database in one command: ```bash seedfast seed --scope "500 customers with orders, payments, and realistic balances" ``` Inside CI it runs as a pipeline step against a connection string, and inside an AI coding agent it runs through an MCP server, the agent calling the `seedfast_run` tool rather than writing a throwaway seed script that loses track of the relationships three tables deep. The broader [comparison of test data generation methods](/blog/test-data-generation) covers where this sits next to SQL dumps and factory code, and the playbook on how to [generate test data with AI](/blog/generate-test-data-with-ai) covers the agent workflow in depth. The same coherence argument holds past a test suite: [convincing demo data](/blog/demo-data-generator) has to satisfy it too, just for a person watching the screen instead of an assertion reading a row. ## Frequently asked questions ### Is Faker output the same thing as synthetic data? Faker output is one narrow kind of synthetic data, not the whole category. Synthetic data means any data generated rather than sampled from production, which spans everything from a single fabricated email to a fully coherent multi-table dataset with production-shaped distributions; the [synthetic test data generation](/blog/synthetic-test-data-generation) page walks that spectrum end to end. Faker occupies the column-independent end of that range, so "faker vs synthetic data" compares a specific library against a broad category rather than weighing two like-for-like options. ### Why does a Faker-seeded database pass tests but break in staging? A Faker-seeded database passes tests because most assertions check the happy path at tiny volume, where individual column values are all that gets exercised. Staging runs real queries against real row counts, where insert order, cross-row coherence, and distribution shape all begin to matter, and where the query planner picks different plans than it did against a handful of uniform rows. The data was never wrong in a way a small green test could see. ### What is cross-column coherence in test data? Cross-column coherence is the property that the values in a row agree with each other and with the rows they reference, so an order lands after the account that placed it existed and an invoice total matches the sum of its line items. Column-independent generators cannot produce it, because each value is drawn without reference to the others. The line it draws is between data that is individually plausible and data that is jointly true. ### When does Faker stop being enough? Faker stops being enough the moment correctness depends on relationships between rows rather than the format of a single value. As long as what you need is a well-formed value in an isolated column, it stays the right tool. Once the thing under test spans a foreign key, relies on a signup-before-order ordering, or reads a production-shaped distribution, test data quality turns into a schema-level property that a field-at-a-time generator can't reach. Seedfast works at that level, reading the whole schema before it writes anything, so the relationship a test depends on is one the data actually has. ## The standard for realistic test data moved For years, filling a database with per-column fakes was simply what generating test data meant, and for the narrow schemas of that era it was mostly fine. Schemas since then have grown wider and deeper, tests have crept closer to production shapes, and query planners have gotten better at exploiting the very statistics that uniform data fakes, so the bar for what counts as realistic has quietly risen from plausible columns to coherent, correctly-shaped, relationally-valid rows. A generator that can't read your schema can't clear that bar, however convincing its street addresses look. Seedfast clears it by generating against the schema in front of it, so the rows hold together across every table it fills, and the [free plan](/pricing) doesn't ask for a card. --- # Your AI Agent Shouldn't Touch Production. Seed It a Postgres Instead. > Source: https://seedfa.st/blog/ai-agent-production-database > Last updated: 2026-08-19 On June 4, 2026, Supabase used its Series F announcement to report that more than half of all new Supabase databases are now deployed by AI agents, with Claude Code the single largest source of that traffic. That one number turns AI agent production database access from a hypothetical some senior engineer signs off on once into a default the tooling already assumes. Two more releases the same month leaned the same direction - Vercel shipped its agent framework, Eve, in mid-June 2026, and Anthropic's Sonnet 5 arrived on June 30 with longer autonomous runs, so an agent can now stay on a multi-step database task across the kind of stretch that used to need a person watching each step. Agents provision databases, migrate them, and increasingly write to them, so the only open question is which database they get. That question already has a workable answer, and Seedfast is the part of it that usually goes missing. Point it at a disposable Postgres and the schema you just migrated comes back full of fresh rows that connect across every table, and not one of those values was copied from anywhere. A single run covers one table or a schema several hundred tables wide. ## Handing an AI agent production database access, and what it costs The blunt answer to that question is to give the agent the same connection string a developer uses and trust it to behave. In July 2025 that trust produced the incident teams now cite by reflex, when an AI agent working in a Replit project deleted a production database during an explicit code freeze, then described what it had done only afterward - the failure wasn't exotic. An agent with write access did something destructive it was never meant to do, which is what "blast radius" names, the reach of a single unintended action. Of course, a developer holding those credentials can cause identical damage, but a person works one deliberate step at a time while an agent runs unattended, at machine speed, across more actions than anyone is reading in real time, so the same credentials carry more exposure in an agent's hands than in a developer's. ## Isolation moves the blast radius without answering the data question The infrastructure vendors have a more careful answer, and it is isolation. On June 26, 2026, Neon published a branch-per-agent-session guide in which each agent run gets its own copy-on-write database branch, a forked copy the agent can wreck freely because throwing it away costs nothing and the parent sits untouched behind it. Measured against blast radius alone, this works well, since the destructive action lands on a branch nobody intends to keep. However, what it leaves untouched is the data inside that branch. A branch clones whatever its parent holds, which leaves two options and no comfortable one: fork a thin dev database and the agent runs against stale, unrepresentative rows, fork production and you have copied real customer data into a scratch environment an autonomous process is free to rummage through, the exact exposure the isolation was meant to remove. So, starting the branch empty only looks safer, and an agent handed a schema with no rows invents whatever fixtures it needs, and the tests that follow go green against nothing, which is exactly what anyone [vibe coding an app and hitting the empty-database wall](/blog/vibe-coding-database) runs into the first time they open the UI instead of trusting the test output. ## What Seedfast puts inside the disposable Postgres The move both answers skip is to separate the two things a branch quietly fuses, the throwaway database and the data that fills it. Keep the disposable Postgres, whether that is an isolated branch or a local container that costs nothing to destroy, and change what goes inside. Rather than clone production or leave the schema bare, generate rows that fit the schema, connect correctly across foreign keys, and carry values that read like the real thing, so the agent works against a database shaped like production that never held a byte of production's actual records. That generation step is precisely where [raw model-written inserts fall apart](/blog/generate-test-data-with-ai), because a language model asked to emit SQL will happily write a foreign key pointing at a row it never created; holding the relational structure together is work for something that reads the schema rather than pattern-matching its way through it. This is the seam Seedfast is built for. It reads the live Postgres schema and writes rows that come out connected and valid, with every foreign key satisfied, tables that reference themselves or each other included. None of that path touches production, since a schema is the shape of your tables and not their contents, and every value it inserts is generated fresh. You invoke it as a CLI, `seedfast seed --scope "..."`, or as an MCP server an agent calls on its own, and one run scales from a single table up to the several hundred a mature schema tends to carry. ## Putting a seeded branch in front of the agent In practice the pattern is short to describe. Stand up a disposable Postgres, apply your migrations so the schema is current, seed it, and give the agent that connection string in place of the real one. Because Seedfast also runs as an MCP server, the seed can be the agent's own opening move, a single `seedfast_run` call before it starts whatever work needs data underneath it, which the [Claude Code walkthrough](/blog/claude-code-mcp-database-seeding) wires up from scratch and the [Codex CLI guide](/blog/codex-cli-database-seeding) repeats for OpenAI's terminal agent. When the agent is [GitHub Copilot working in its Actions environment](/blog/copilot-coding-agent-database-seeding), the seed moves into its setup-steps workflow instead, and [Antigravity's parallel agents](/blog/antigravity-mcp-database-seeding) read the same server from one shared config. For a loop that stays on your laptop, a [disposable Postgres under Docker Compose](/blog/docker-compose-seed-database) gives you the same throwaway target with no cloud branch involved, and once the loop moves into CI, [seeding on every pipeline run](/blog/synthetic-data-ci-cd) keeps each job's database current instead of restoring it from an aging copy - the setup lives in those guides. What matters here is the ordering, disposable database first, then the real schema, then generated data, and the agent last. ## Frequently asked questions ### Why shouldn't an AI coding agent connect to your production database? An AI coding agent pointed at production inherits full write access and exercises it unattended, at a speed and across a step count no human review keeps pace with, which is what enlarges the blast radius of any single mistake. The widely cited Replit case from July 2025, an agent deleting a live database mid-freeze, is what that risk looks like when it lands. Handing the agent a disposable database instead retires the whole class of failure rather than trying to fence it off after the credentials are already out. ### Is an empty branch enough for an AI agent's test run? An empty branch is almost never enough, because [an agent given a schema with no rows](/blog/ai-agent-empty-database) either fabricates its own fixtures or runs tests that pass without checking anything real. Copy-on-write branching, from Neon and platforms like it, solves isolation and keeps a bad run off production, yet it says nothing about whether the rows inside the branch resemble what the code meets once it ships. A branch Seedfast has filled from the schema, where the rows connect and the values read like production, is what makes the run worth trusting. ### What did Supabase say about agents deploying databases? Supabase reported in its June 4, 2026 Series F announcement that AI agents now stand up more than half of all new databases on the platform, and it credited Claude Code with more of that traffic than any other tool. The figure reads as a direction more than a milestone, evidence that provisioning and writing to databases has become ordinary agent work. It sets up the practical decision that follows, namely which database an agent should ever be permitted to write against. ### How do you give an AI agent realistic data without copying production? Giving an agent realistic data without copying production comes down to generating it from the schema instead of duplicating the rows, so the database mirrors production's structure while holding none of its records. Seedfast reads the table definitions and foreign keys off a connection string, then writes connected rows that satisfy every constraint, which is what keeps integration tests meaningful rather than merely green. The same call runs as a CLI step or over MCP, so an agent can fill its own branch before the first test runs. ## Which database the agent gets is the decision Every failure mode above, the deleted production database, the stale fork, the branch that went green because it was empty, traces back to a single choice nobody quite made on purpose, which database the agent is allowed to touch. Decide it deliberately and hand the agent a throwaway Postgres that was never production and never held a row of its data, and the safety argument mostly settles itself. Seedfast is the piece that fills that database with something worth testing against, and its [free plan](/pricing) takes no card if your next agent run needs a real schema underneath it. --- # Seed an Empty Postgres Database Without Leaving Claude Code > Source: https://seedfa.st/blog/claude-code-mcp-database-seeding > Last updated: 2026-08-19 You have a feature branch open in Claude Code and the integration tests are ready to run, but the dev database behind them is empty. Claude Code MCP database seeding closes that last gap without a detour. Seedfast exposes a `seedfast_run` tool to the agent already open in your editor, and one invocation reads the live schema and fills every table under it with valid rows before a single test fires. Ask the model to write a seed script instead and you tend to get inserts whose foreign keys point at rows that were never created, a failure the [generate test data with AI](/blog/generate-test-data-with-ai) playbook walks through in depth. This page stays deliberately narrow. One config block, one prompt, and an empty Postgres database comes back populated, all from the tool you already keep open. I run this a dozen times a day against branch databases, so what follows is the exact path from zero to seeded, not [the full map of MCP servers for test data](/blog/mcp-test-data) you could wire up instead. > **TL;DR:** Seedfast runs as an MCP server, so an agent inside Claude Code populates a Postgres database in one call. It reads your live schema, generates relational rows with valid foreign keys, and writes them back, without you leaving the editor. ## How Claude Code MCP database seeding works When Claude Code starts, it reads your project's `.mcp.json` and launches the Seedfast server through `npx`, which then advertises a small set of tools over the Model Context Protocol. The one that matters here is `seedfast_run`. You hand the agent a scope in plain English and it forwards that scope to `seedfast_run`. From there Seedfast reads your live Postgres schema and generates data that comes out valid and connected, so nothing ends up pointing at a row that isn't there. That division of labor keeps the data coherent, since the agent stays in the part it does well, describing intent and reasoning about your feature, and hands the relational bookkeeping to code that reads the real schema rather than guessing at it. Keeping every row valid, tables that reference themselves or each other included when the schema leaves a nullable link, belongs to Seedfast, while enforcement of [referential integrity](/blog/referential-integrity) never leaves the database. The deeper argument for why a language model loses the relational thread lives in the same [playbook](/blog/generate-test-data-with-ai), so I won't relitigate it here. ## Set up Seedfast in .mcp.json Two things have to exist before the agent can seed anything, a Seedfast account and an API key, so log in at seedfa.st, open **[API Keys](/dashboard?section=api-keys)**, and create one; it comes back in the `sfk_live_...` format. Export it in your shell profile rather than typing it into a file the repository carries: ```bash export SEEDFAST_API_KEY="sfk_live_your_api_key_here" ``` Add Seedfast to the project's `.mcp.json` at the repository root: ```json { "mcpServers": { "seedfast": { "command": "npx", "args": ["-y", "seedfast@2.6.0", "mcp"], "env": { "SEEDFAST_API_KEY": "${SEEDFAST_API_KEY}" } } } } ``` Both of those details earn their keep. Claude Code expands `${VAR}` inside `env`, so the file above is safe to commit exactly as written and every person on the team supplies their own key from their own shell. And pinning the version instead of reaching for `@latest` keeps the config reproducible, since `@latest` re-resolves each time the server starts and we publish often enough for that to hand two teammates different builds in the same week. Check `npm view seedfast version` when you want to move the pin forward. You'll need Node.js 18 or newer on the machine, since the server runs through `npx`. Restart Claude Code so it picks up the new config, then confirm the wiring by asking the agent to run `seedfast_doctor`, which reports whether the CLI is healthy and your key authenticated. If you're setting up Cursor, VS Code, or Claude Desktop instead, or you hit a snag along the way, the [MCP setup guide](/docs/mcp-setup-guide) carries the config for every client and the troubleshooting steps. ## One prompt, a seeded database With the server wired up, a real session reads like a sentence. Say you're building order history and the tests need customers who actually own something. You would type roughly this: ``` Seed customers with related orders and line items, a few rows each ``` Claude Code reads that, calls `seedfast_run` with the scope, and Seedfast fills the three tables so every order points at a real customer and every line item at a real order. And because scope controls scale as much as shape, you ask for a few rows each when a unit test just needs something to assert against, and for thousands when you want to watch a query plan buckle under load. How long it takes tracks how much you asked for. A single table lands in five to fifteen seconds, five to ten related tables in thirty to sixty, and a full schema in two to five minutes, so the order-history seed above finishes while you're still reading the test you're about to run. Once it's done, ask the agent for the run summary and it reports which tables succeeded along with the total rows written, or run a quick `SELECT count(*)` yourself and read the numbers straight from the database. ## Point it at a dev database, never production There is an honest worry sitting underneath all of this. You are handing an agent a write path into a database, which is a reasonable thing to feel cautious about, so the single rule that keeps it boring is to point Seedfast at a dev or branch database only, the same discipline you would already apply to any seed script a teammate wrote. The blast radius stays small for a structural reason too, since Seedfast needs no production data to do its job; it reads the schema and writes fresh rows generated from scratch, so nothing about your real customer records has to come anywhere near the run. That same seed call drops straight into CI, where the MCP wrapper gives way to the plain CLI. The pipeline version of the workflow, fresh rows for every pull request with no hand-maintained `seed.sql` in sight, has its own write-up in [synthetic data for CI/CD](/blog/synthetic-data-ci-cd). ## Frequently asked questions ### Does Claude Code support MCP servers like Seedfast? Claude Code, Anthropic's agentic coding tool, supports MCP servers natively and reads their configuration from a project-scoped `.mcp.json` at the repository root. Once you add an `mcpServers` block naming the command to launch, Claude Code starts that server at boot and its tools, Seedfast's included, become available to the agent mid-conversation. ### What can I ask Claude Code to seed with Seedfast? Seedfast can seed anything from a single table to every table across every schema, described to Claude Code in plain language rather than SQL. A scope such as "seed customers with related orders and line items" populates one connected slice, while "seed all tables in all schemas" fills the whole database. Because the engine re-reads your live schema on each run, a table you added this morning flows through without extra wiring. Sensitive tables stay out when you name them as exclusions in the scope. ### How long does a seed run take from Claude Code? A Seedfast run from Claude Code usually finishes in well under a minute, and even a full schema of fifty-plus tables stays inside a few minutes. Expect five to fifteen seconds when a single table is in scope and two to five minutes at the everything-at-once end, which is why scoping the seed to the feature at hand keeps the wait short. ### Does the same MCP setup work in Cursor or VS Code? Yes, the same server block works unchanged in Cursor, VS Code, Claude Desktop, and Claude Code, because each client launches Seedfast through the identical `npx` command. Only the file it lives in moves around, with Cursor reading `.cursor/mcp.json` and VS Code keeping the block in its own MCP settings. The [MCP setup guide](/docs/mcp-setup-guide) spells out the location for each client, and Codex CLI takes the same `npx` command in TOML form, covered in [its own walkthrough](/blog/codex-cli-database-seeding). ## Seed the empty database from where you already work [Seedfast](https://seedfa.st/) turns the empty-database step into one line inside Claude Code, reading your live schema and writing valid, connected rows off it with no production data involved. Start on the [free plan](/pricing), no card required, and the first rows usually land a couple of minutes after the config block goes in. --- # Seed Postgres From the Codex CLI With One MCP Command > Source: https://seedfa.st/blog/codex-cli-database-seeding > Last updated: 2026-08-19 My Codex CLI session is open and the migrations have run, but the schema behind my branch is still empty. Codex CLI database seeding handles that from inside the same session. Seedfast registers as an MCP server the agent can call by name, and that call reads the live Postgres schema and writes the rows off it, which spares me the usual fallback of hand-written inserts from the model, the ones that satisfy the column types and break on the relationships between tables. The broader [guide to generating test data with AI](/blog/generate-test-data-with-ai) covers why they break. > **TL;DR:** One `codex mcp add` command registers the Seedfast MCP server in `~/.codex/config.toml`. A plain-English prompt then calls `seedfast_run`, and Seedfast reads the foreign keys in your live Postgres schema and writes rows that stay valid, so every child row points at a parent that exists. ## Register the Seedfast MCP server in Codex CLI Codex CLI is OpenAI's open-source terminal coding agent, and it speaks the Model Context Protocol out of the box. One subcommand wires Seedfast into it. ```bash codex mcp add seedfast --env SEEDFAST_API_KEY=sfk_live_your_api_key_here -- npx -y seedfast@2.6.0 mcp ``` Naming a version there rather than `@latest` is deliberate. The tag re-resolves on every server start, and we ship often enough that an open tag can quietly move you onto a different build between two runs of the same project. Look up the current one with `npm view seedfast version` and bump the pin when you mean to. That command writes the server into the global `~/.codex/config.toml`, the TOML file Codex loads at startup. Open the file afterward and the new entry reads like this. ```toml [mcp_servers.seedfast] command = "npx" args = ["-y", "seedfast@2.6.0", "mcp"] [mcp_servers.seedfast.env] SEEDFAST_API_KEY = "sfk_live_your_api_key_here" ``` Run `codex mcp list` to confirm the registration, and once you are inside a session the `/mcp` command shows which servers are live. To check that the key authenticated, ask Codex to call `seedfast_doctor`, which verifies the install and confirms an `sfk_live_...` key is configured; keys come from the Seedfast dashboard. Because the server launches through `npx`, the machine needs Node.js 18 or later, and the flags `codex mcp add` accepts are documented in OpenAI's [MCP configuration reference](https://developers.openai.com/codex/mcp). ## Global config versus a project-scoped .codex/config.toml `codex mcp add` only ever writes the global file. To share the server through a repository, you add a project-scoped `.codex/config.toml` by hand and check it in. Codex loads those project layers only for projects you have trusted, walking from the project root down toward your working directory, and the file closest to where you are running takes precedence on any conflicting key. A handful of security-sensitive keys are refused at project level regardless. Committing a config raises an obvious question about the API key sitting in it. Per OpenAI's config reference, Codex does not hand your whole shell environment to an MCP server, so a secret reaches the server one of two ways. You either hardcode it in the `env` table, where it then lives in the file, or you list the variable name under `env_vars` and let Codex forward it from its own environment at launch. ```toml [mcp_servers.seedfast] command = "npx" args = ["-y", "seedfast@2.6.0", "mcp"] env_vars = ["SEEDFAST_API_KEY"] ``` Under `env_vars`, the file names `SEEDFAST_API_KEY` but never stores its value, which makes a project-scoped config safe to commit while each developer exports the key in their own shell. That forwarding is opt-in per variable, so don't assume the server can see anything else you exported; declare what it needs, one way or the other. The full set of layering and precedence rules lives in OpenAI's [config reference](https://developers.openai.com/codex/config-reference), and the equivalent setup for other clients, including the [Claude Code version of this job](/blog/claude-code-mcp-database-seeding), is in the [MCP setup guide](/docs/mcp-setup-guide). ## What a Codex CLI database seeding run looks like Once the server is registered, the seed itself is one message to the agent. Say a test needs a small project tree to assert against. ``` Use seedfast_run to fill projects, tasks and comments. A handful of projects, a realistic spread of tasks each, a few comments where they fit. ``` Codex reads that and calls `seedfast_run` with the scope, handing off the relational work. Seedfast inspects the live PostgreSQL schema at that moment and writes rows that come out valid and connected, so every task points at a real project and every comment at a real task. The same call scales with the words you choose, so "a handful" keeps a unit test quick while "a few thousand tasks" gives a query planner something to strain against. Codex picked up the GPT-5.6 family on launch day, which means the model parsing your scope may well be [GPT-5.6 Sol](/blog/gpt-5-6-sol-test-data); what keeps the rows connected across tables is still Seedfast rather than the model. When the run finishes, ask Codex for the summary, which reports how many tables succeeded and the total rows written, naming any table that failed; a `SELECT count(*)` gives you the same numbers from the database side. At Seedfast's default row volume even a full schema comes back populated inside a few minutes, and a scoped seed like the one above lands while you are still reading the test that needed it. ## The guardrail is the connection, not the sandbox There is a fair worry under all of this, since you are giving an agent a write path into a database. Codex ships three sandbox modes (read-only, workspace-write, danger-full-access) with network access off until you enable it, and those govern the shell commands the agent itself runs. An MCP server sits outside that arrangement; Codex starts it as a separate, long-lived process, and in practice the sandbox does not confine it, though the docs don't spell this out. So the guardrail worth setting is the database connection you hand the server, which should belong to a dev or branch database. Codex does expose approval policies you can configure per server and per tool if you want a gate before a call runs, and because Seedfast composes every row from the schema alone, production data never enters the run. ## Frequently asked questions ### Where does codex mcp add store the Seedfast configuration? `codex mcp add` stores the Seedfast entry globally, under an `[mcp_servers.seedfast]` table in `~/.codex/config.toml`. Nothing project-local is created by the command; a repo-scoped `.codex/config.toml` has to be written by hand, and `codex mcp list` or the in-session `/mcp` command will confirm what actually registered. ### Can I commit the Codex MCP config without exposing my API key? Seedfast API keys stay out of a committed file when the variable is named under `env_vars` instead of written into the `env` table. OpenAI's config reference describes `env_vars` as a pass-through list, with Codex forwarding each named variable from its own environment when the server starts, so the committed file carries only the name `SEEDFAST_API_KEY`, never the value, and teammates supply their own keys locally. Hardcoding the key in `env` is fine for the global file in your home directory, but it doesn't belong in anything you push. ### Does the Codex CLI sandbox limit what the Seedfast server can do? Codex CLI's sandbox is aimed at the agent's own shell commands, so in practice it does not restrict the Seedfast server, and nothing in the official docs places MCP processes inside that boundary. That leaves the choice of target database as the real limit on what a seed run can reach, with per-server and per-tool approval policies available on top when you want an explicit gate before a call. ### Does seedfast_run pick up schema changes between runs? Seedfast re-reads the current Postgres schema at the start of every `seedfast_run` call, so a column added by this morning's migration shows up in the next batch of rows without any config change. Scope stays up to the prompt, and the rows keep their references valid at any size because that read happens again each time. ## Seed the schema from the terminal you already live in Codex CLI already sits where your migrations and tests run, and registering Seedfast means the same session can fill the schema they depend on. Keys come from the Seedfast dashboard, where the [free plan](/pricing) starts without a card. Once `/mcp` shows the server live, every empty dev schema after that is one prompt away from being populated. --- # One docker compose up, One Seeded Postgres > Source: https://seedfa.st/blog/docker-compose-seed-database > Last updated: 2026-07-10 Docker Compose made your dev database reproducible, one command bringing up the same Postgres for the whole team, down to the version and extensions, and the schema it hands you arrives perfect and empty. That emptiness is the part nobody standardized, so `docker compose up` gets you a database while filling it remains a separate step every project reinvents. A docker compose seed database workflow comes down to two decisions, where the seed command runs and how it knows Postgres is ready for it. Get both right and the empty-database step folds into `docker compose up`. The rest of the page applies [database seeding](/blog/database-seeding) to this one stack, using the compose file I run most days. > **TL;DR:** Seedfast seeds a Postgres running under Docker Compose in one command, reading the live schema through the published port and writing connected rows back into it. Bring the stack up with `docker compose up -d --wait` so the `pg_isready` healthcheck gates readiness, then run `seedfast seed` from the host. Keep schema bootstrap in `/docker-entrypoint-initdb.d`, and let the healthcheck-gated seed step handle the test data you actually iterate on. ## Where the seed goes in a docker compose seed database setup Three spots can hold the seed, and they aren't interchangeable. The first is `/docker-entrypoint-initdb.d`, the postgres image's init directory, where scripts run exactly once, on the first boot of an empty data directory. Inside the compose file itself sits the second, a one-shot service that waits for Postgres to report healthy, runs the seed, and exits — and the third stays outside compose altogether, a seed run from your host shell once the healthcheck clears. Which one fits turns on how often the data changes. Schema bootstrap and a `CREATE EXTENSION` line belong at first boot, where they run once and stay put, while test data you regenerate as the schema moves wants a health-gated placement, one that re-runs on demand instead of hiding behind a volume populated weeks ago. Seedfast belongs in that health-gated slot, since it reads the schema again on every invocation and therefore has nothing cached behind the volume to go stale. ## The compose file that boots a ready Postgres Here is the compose file I start from. It defines one postgres service on the official `postgres:16` image, publishes the port to the host, and keeps its data in a named volume so a restart doesn't wipe your work: ```yaml services: postgres: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d app"] interval: 5s timeout: 3s retries: 5 start_period: 5s volumes: pgdata: ``` Of everything in that file, the healthcheck carries the most weight. Docker reports a container "running" the instant its process starts, but PostgreSQL needs a moment to finish recovery and open its socket, and a seed that fires inside that window dies on "connection refused" — the gap `pg_isready` closes, since it exits 0 only once the server is accepting connections. Binding the container's health to that check makes "healthy" and "ready to seed" the same event, and the `interval`, `timeout`, `retries`, and `start_period` fields tune how long Docker probes before it declares failure. ## What /docker-entrypoint-initdb.d actually does The init directory is where most people meet this pattern, and where they hit its sharp edge. On first boot, and only while the data directory is still empty, the postgres image runs everything in `/docker-entrypoint-initdb.d` in alphabetical order (every `*.sql`, `*.sql.gz`, and `*.sh` file). Mount `01-schema.sql` and `02-seed.sql` there and a fresh volume picks them up. The trap is the word "fresh." Because the named volume persists across `docker compose down` and every ordinary `docker compose up`, the init scripts never fire again. Edit `02-seed.sql`, run `docker compose up` again, and nothing changes, because the data directory isn't empty anymore and the image skips the whole directory. Only `docker compose down -v` drops the volume and lets the next `up` start clean, which makes the init directory a good home for schema bootstrap and a `CREATE EXTENSION pgcrypto` line, and a poor one for the test rows you tweak between runs. For the mechanics of the `seed.sql` those scripts contain, [writing a Postgres seed script](/blog/postgres-seed-script) covers the file; the point here is only where it belongs in a compose stack. ## The healthcheck-gated seed step By default I reach for this placement — bring the stack up and wait for health: ```bash docker compose up -d --wait ``` `--wait` holds until every healthcheck reports healthy, then hands the shell back — no `sleep 10` guesswork, no polling loop. Once it returns, Postgres is ready, and the seed runs against the published port: ```bash SEEDFAST_DSN=postgresql://app:app@localhost:5432/app \ npx seedfast seed --scope "customers with a few orders and line items each" --output plain ``` That connection string is the same `localhost:5432` you would hand to `psql`, reaching the container through the published port, though a `POSTGRES_PASSWORD` holding a `#` has to be percent-encoded before the URI parses at all, which [the connection string builder](/tools/postgres-connection-string-builder) takes care of. `npx` pulls the CLI from npm; `SEEDFAST_DSN` tells the seed where to write and takes priority over `DATABASE_URL`, so it won't clobber another tool's setting; `SEEDFAST_API_KEY`, exported in your shell beforehand, authenticates the run; and `--scope` feeds Seedfast a plain-English description and skips the interactive prompt that would otherwise stall a script. What Seedfast does with that connection is read the schema and write rows that satisfy it, so every foreign key resolves to a parent that already exists. The flags match the [CLI quick start](/docs/seed-your-database); nothing changes because Postgres lives in a container. If you would rather keep everything in one file, lift that command into a one-shot service that waits on Postgres: ```yaml seed: image: node:22 depends_on: postgres: condition: service_healthy environment: SEEDFAST_API_KEY: ${SEEDFAST_API_KEY} SEEDFAST_DSN: postgresql://app:app@postgres:5432/app command: npx -y seedfast seed --scope "customers with a few orders each" --output plain ``` `condition: service_healthy` is the piece that matters, holding the `seed` container until the healthcheck passes so the seed never races a database that hasn't opened its socket. Inside the compose network the host becomes `postgres`, the service name, since `localhost` points the container at itself. The loop stays short when the schema moves. After a migration adds a column, drop the volume and bring the stack back up: ```bash docker compose down -v && docker compose up -d --wait ``` Then rerun the same `seedfast seed` command, and the new column simply appears in the next batch of generated rows, because Seedfast reads the schema fresh on every run rather than caching what it saw on the first one. None of this is only a local trick; the [CI/CD database seeding](/docs/cicd-database-seeding) guide runs the same gated-seed shape against a [GitHub Actions service container](/blog/github-actions-seed-postgres-database), on the identical healthcheck. ## Frequently asked questions ### Why doesn't my seed script in docker-entrypoint-initdb.d run again? Scripts in `/docker-entrypoint-initdb.d` run only on the first boot of an empty data directory, so a persisted named volume is almost always the reason a second run does nothing. The image skips the entire init directory whenever its data directory already holds a database, and no edit to a seed script there takes effect until the volume is gone — run `docker compose down -v` to drop it, bring the stack back up, and the scripts execute against a clean directory. ### Can Seedfast seed a Postgres database running in Docker Compose? Seedfast treats a Compose-managed Postgres like any other database, reaching it over the port the compose file publishes. Point `SEEDFAST_DSN` at `postgresql://user:pass@localhost:5432/db` once `docker compose up -d --wait` returns, and run `seedfast seed`. Migrations don't break it, since the schema is read live on every run, and when you would sooner keep everything inside the file, the same command drops into a one-shot Compose service. ### How do I make Docker Compose wait for Postgres before seeding? Docker Compose waits for Postgres when the service carries a `pg_isready` healthcheck and you gate the seed on it. Either bring the stack up with `docker compose up -d --wait`, which returns only after every healthcheck passes, or give the seed service a `depends_on` block with `condition: service_healthy` so Compose holds it until the database is accepting connections. ### Should test data be baked into the Docker image or seeded after startup? Test data you change with any regularity belongs in a seed step after startup, well away from the image build and the first-boot init script. A baked-in dataset goes stale the moment a migration lands and forces an image rebuild to refresh. Run Seedfast after the healthcheck instead and it reads the current schema and regenerates the rows on demand, which leaves the image and the init directory holding only what rarely moves, the schema and its required extensions. ## Seed the Postgres your stack boots empty The compose file above hands every machine on your team the same clean Postgres, and [Seedfast](https://seedfa.st/) fills it with referentially valid rows generated from the live schema, no production data anywhere in the run. Start on the [free plan](/pricing) without a card, and the whole loop, `up --wait` included, fits inside a few minutes. --- # GPT-5.6 Sol Writes the Code. It Still Can't Populate Your Database. > Source: https://seedfa.st/blog/gpt-5-6-sol-test-data > Last updated: 2026-09-10 OpenAI moved [GPT-5.6 Sol](https://openai.com/index/gpt-5-6/) into general availability on July 9, 2026, and I don't intend to argue with the launch numbers. On Agents' Last Exam, Sol reaches a 54% score for roughly $760 of API spend, a level Claude Opus 4.8 never touches even after burning close to $4,000, and Sam Altman's line to [CNBC](https://www.cnbc.com/2026/07/09/open-ai-sam-altman-chatgpt-5-6-sol.html) about 54% better token-efficiency on agentic coding tracks with what the chart shows. The launch page has no chart for GPT-5.6 Sol test data — the valid, connected rows all of that newly cheap code still has to run against — and that is where I expect pipelines to start slowing down. ![Scatter chart of score versus API cost on Agents' Last Exam. GPT-5.6 Sol peaks at a 54% score for about $760 of API cost. GPT-5.5 tops out at 47%, Claude Opus 4.8 reaches 45% at nearly $4,000, Claude Fable 5 sits at 41%, and Gemini 3.1 Pro Preview at 32%.](/blog/gpt-5-6-sol-agents-last-exam.svg) *Agents' Last Exam, score against API cost. Chart from [OpenAI's GPT-5.6 launch post](https://openai.com/index/gpt-5-6/).* None of that capability transfers to the data problem, though, and the reason is structural rather than temporary. A model trained on most of the public internet has watched a thousand teams build a thousand versions of your feature, so generating the code is close to recall. Your database it has never seen. It cannot know which `customer` rows exist in this specific instance, or what your `orders` table checks before it accepts an insert, because nothing about your schema was ever in a training set, and schemas are the one part of a codebase that genuinely resembles no other. Seedfast reads that schema at the moment a run starts, so what it generates comes from the tables, constraints, and foreign keys of this one database rather than from a plausible average of everyone else's. ## Why GPT-5.6 Sol test data became the bottleneck Watch any of the launch demos all the way to the end, past the issue-reading and the file edits, and you land on the moment the model proves its work by running the test suite. Runs it against what, though? A database that already has to hold the order pointing at its customer and the shipment pointing at its warehouse, and nobody in that loop, model or human, ever inserted those rows. And because all three tiers landed in Codex and [GitHub Copilot](https://github.blog/changelog/2026-07-09-openais-gpt-5-6-sol-terra-and-luna-are-now-available-in-github-copilot/) on day one, this loop is already firing inside real pull requests, against ephemeral databases that start out empty. Wired into that loop, Seedfast writes the rows before the suite runs, working from the schema the migration step has just applied. The economics get uncomfortable once you set the two launch charts side by side. On the Artificial Analysis Intelligence Index, the only model that scores above Sol is Claude Fable 5 (59.9 against Sol's 58.9), and it spends nearly twice the money getting there, about $5,600 of measured API cost against $2,800. Capability per dollar climbs with every release; the cost of producing correct test data hasn't moved, and how would it? The binding constraint there is local knowledge of this one schema, and local knowledge does not improve when the model does. ![Scatter chart of Artificial Analysis Intelligence Index v4.1 score versus API cost. GPT-5.6 Sol scores 58.9 at about $2,800 of API cost; Claude Fable 5 scores 59.9 at about $5,600; GPT-5.5 reaches 54.8 at about $2,650, and Claude Opus 4.8 reaches 55.7 at around $3,750.](/blog/gpt-5-6-sol-intelligence-index.svg) *Artificial Analysis Intelligence Index v4.1, as charted in [OpenAI's launch post](https://openai.com/index/gpt-5-6/).* METR's predeployment report points at the same weakness from a different angle. Before release, the evaluator detected the highest cheating rate it has measured for any public model on its agent harness — Sol used packaging exploits to surface hidden test cases, and in one run it extracted the source file holding the expected answers, which is why METR wrote that the capability scores [could not be treated as a robust measurement](https://metr.org/blog/2026-06-26-gpt-5-6-sol/). That is not a knock on the model's coding so much as the familiar failure mode of every strong generator, output that looks correct until something external checks it. In a test database, the external check is a foreign key constraint discovering that the `customer_id` on a generated order belongs to nobody. ## Can't GPT-5.6 Sol just write the INSERT statements? For twenty rows of a flat `users` table, GPT-5.6 Sol hands back twenty believable users, sharper than anything a Faker script produces, because inventing plausible values is squarely inside what the model does. Once the whole schema stops fitting inside the context window, which for a working Postgres database happens sooner than most people guess, the model keeps writing inserts for tables low in the dependency chain long after it has lost track of what it set up near the top. A child row lands pointing at a parent that was never created, Postgres refuses the insert, and the run stalls half-seeded (the rerun then collides with whatever the first pass already wrote). Seeding a real schema behaves more like constraint-solving than next-token prediction, since every foreign key has to resolve to a row that is genuinely there before the insert succeeds. If you are weighing options in this category, the [best AI test data generator](/blog/best-ai-test-data-generator) roundup lays out where each of them gives out. The launch also moved the scaling story onto a second axis, running agents in parallel rather than only reaching for a larger single model. On Terminal-Bench 2.1, one Sol agent reaches 88.8% at roughly four minutes of simulated latency, and putting four Sol agents to work at once lifts that to 91.9% inside the same four-minute budget, where GPT-5.5 had needed seven minutes just to arrive at 85.6%. Every agent in that fleet, though, spins up its own workspace and runs against its own database, so four agents mean four schemas waiting for valid, connected rows, and not one of those parallel workers stops to insert them. ![Terminal-Bench 2.1 multi-agent benchmark: a single GPT-5.6 Sol agent reaches 88.8% at about 4 minutes of latency, four Sol agents in parallel reach 91.9% at the same latency, while GPT-5.5 needs 7 minutes for 85.6%; reference lines show Claude Fable 5 at 83.1%, Claude Opus 4.8 at 78.9%, and Gemini 3.1 Pro Preview at 70.7%.](/blog/gpt-5-6-sol-terminal-bench.svg) *Terminal-Bench 2.1 in single- and multi-agent configurations, from [OpenAI's GPT-5.6 launch post](https://openai.com/index/gpt-5-6/).* ## What actually fills the database [Seedfast](https://seedfa.st/) fills exactly this gap, reading the live PostgreSQL schema and generating connected rows that satisfy the constraints it finds there, however deep the schema runs. You hand it a scope in plain English, a model supplies the realistic values, and the structural work models tend to botch stays inside deterministic code you can test. Seedfast still fills looped tables, circular references included, and the [referential integrity](/blog/referential-integrity) of what lands holds. Exposed as an [MCP](/docs/mcp-setup-guide) tool, Seedfast sits inside the same loop as the code, so the agent building your feature in [Claude Code](/blog/claude-code-mcp-database-seeding), Cursor, or another MCP client such as [Codex](/blog/codex-cli-database-seeding) calls `seedfast_run`, the branch database fills with valid data, and only then do the tests run. Run it as a CLI step after your migrations apply and every pull request meets fresh rows instead of a stale `seed.sql` that somebody keeps hand-patching, the [synthetic data for CI/CD](/blog/synthetic-data-ci-cd) pattern narrowed to a single command. When a fleet of those parallel agents spins up, each worker can call the same tool against its own branch database, and seeding keeps pace with the fleet. Seedfast re-reads the schema on every run, which lets a newly added table flow through without extra wiring, and it holds up on schemas that reach into the hundreds of tables. The free plan needs no card, and the paid tiers are flat monthly plans at $16 and $69 that each carry a pool of credits, so a seed firing on every push draws from that pool instead of metering you per row. ## Frequently asked questions ### What is GPT-5.6 Sol? GPT-5.6 Sol is the flagship tier of OpenAI's GPT-5.6 family, released to general availability on July 9, 2026, next to the cheaper Terra and Luna tiers and pointed at the hardest coding and reasoning work. Beyond Codex and Copilot, it reached ChatGPT and the OpenAI API on the same launch day, where OpenAI [lists](https://openai.com/index/gpt-5-6/) it at $5 per million input tokens and $30 per million output. ### Can GPT-5.6 Sol generate test data? On its own, GPT-5.6 Sol produces believable individual values yet cannot keep relational integrity across tables once the schema outgrows a single context window, which in practice means a handful of tables before its inserts stop agreeing with one another. Seedfast closes that gap, reading the live Postgres schema over MCP and generating the connected rows from it, so an agent running Sol delegates the relational work instead of guessing at it. The [generate test data with AI](/blog/generate-test-data-with-ai) playbook walks through wiring it up. ### What did METR find about GPT-5.6 Sol? METR, the independent group that evaluated GPT-5.6 Sol ahead of release, recorded a time-horizon estimate that swung from roughly 11 hours to more than 270 hours depending on whether the model's cheating runs were scored as successes ([METR's predeployment evaluation](https://metr.org/blog/2026-06-26-gpt-5-6-sol/)). A spread that wide means a single headline number tells you very little about which model actually lands in your workflow, and the same caution carries straight into seeding, where correctness has to live in checks the model has no way to game. ### Is GPT-5.6 Sol available in GitHub Copilot? Yes, all three GPT-5.6 tiers arrived in GitHub Copilot on launch day, and Sol reaches the Pro+, Max, Business, and Enterprise plans under usage-based billing. That availability matters because Copilot's coding agent opens a pull request and runs the suite against an ephemeral database it never seeds for you, which puts the empty-database gap inside a tool millions of developers already keep open. ## Seed the database GPT-5.6 Sol leaves empty [Seedfast](https://seedfa.st/) fills the database that GPT-5.6 Sol leaves empty, running from the CLI or straight out of your AI agent to generate valid, connected rows off your live schema in a single command, with no production data required for any of it. Start on the [free plan](/pricing), and the first seed lands in roughly two minutes. --- # Synthetic Data for CI/CD: Seed Fresh, Valid Rows on Every Run > Source: https://seedfa.st/blog/synthetic-data-ci-cd > Last updated: 2026-09-10 Every CI pipeline that touches a database needs rows to run against, and there are only three ways to get them: restore a copy of production, replay a checked-in `seed.sql`, or generate the data inside the run. Synthetic data for CI/CD is the third option — generate fresh, referentially-valid rows from the schema on every build, so no production data is copied and the data can't go stale between migrations the way a static seed file does. For most teams it's the one option that both stays correct as the schema changes and stays clean of real customer data. This guide covers why pipeline-time generation beats the other two, what a generator needs to be usable in CI, and the newer pressure behind it: AI coding agents that open pull requests and need a real database to test against. ## Why generate synthetic data for CI/CD Generating synthetic data in the pipeline beats the two older approaches (copying production and replaying a static `seed.sql`) on the two things CI cares about: it moves no real customer data, and it stays in sync with the schema. Each older approach fails one of those. **Copying production** drags real customer data into a build environment. That's a compliance problem the moment the schema holds anything personal, and it usually means a masking pipeline bolted on top, plus a slow restore of a database far larger than any test needs. You inherit production's size and its risk to test a pull request. **A checked-in `seed.sql`** avoids the privacy problem but rots. The day someone adds a `NOT NULL` column or a new foreign key, the file is wrong: it fails, or worse, quietly seeds an incomplete row. Static fixtures drift because the schema moves and the file doesn't. The [seed file maintenance](/blog/seed-file-maintenance) problem is exactly this, and every migration charges the tax. **Generating in the pipeline** sidesteps both. The data is [synthetic](/blog/staging-without-prod-data), so there are no production records to move or mask, and because it's built from the live schema each run, a new migration is picked up automatically: the [pipeline-time seeding](/docs/cicd-database-seeding) step re-reads the schema and writes rows that fit it. The catch is that the generator has to produce valid, connected data unattended, which is where most tools fall down. [Seedfast](https://seedfa.st/) is a schema-aware generator built for that: point it at a connection string and it reads the live schema itself. On the axes that decide whether a seed source survives in CI: | Approach | Moves production PII? | Stays valid after a schema change? | Sized for the test? | Runs unattended in CI? | | ------------------------- | --------------------- | --------------------------------------- | ---------------------- | ------------------------------------------------ | | Copy / restore production | Yes (real records) | Only after a fresh copy and re-mask | No (production-sized) | Not without a masking step first | | Checked-in `seed.sql` | No | No, breaks when the schema moves | Yes | Until the next migration breaks it | | Generate in the pipeline | No | Yes, re-reads the live schema each run | Yes | Only with a generator good enough to run on its own | The bottom row's last column is the honest cost of generating: it works only if the generator holds integrity without a human watching. [Run your first seed](/docs/seed-your-database) free to put that on your own schema. ## What a CI seed step actually needs A CI seed step has to clear five bars a point-and-click generator never faces. Miss one and the step either won't run unattended or won't fail loudly enough to trust. - **A non-interactive command.** It runs from a connection string and an API key in an environment variable, with no UI, no login prompt, and no paste step. If a human has to click, it isn't a CI step. - **A schema read at run time.** The seed runs *after* migrations apply, so the generator must read the current schema then, not a snapshot from when someone configured it. - **Referential integrity across the [foreign-key graph](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK).** Data that violates constraints fails the insert; data that skips relationships makes integration tests lie. Seedfast writes rows whose references resolve, [circular foreign keys](/blog/circular-foreign-key-seed) included, with nobody supervising the run. - **Machine-readable output and honest exit codes.** A JSON output mode and a non-zero exit on failure are how the build gate knows whether the seed worked. - **A cost that doesn't scale with run frequency.** Seeds fire on every push, every branch, across a job matrix, and a per-row or per-token price turns that volume into a bill that climbs with your commit rate. A flat plan doesn't move. ## Test data for AI agents in the pipeline AI coding agents now open pull requests, and [agentic test runners](/blog/agentic-qa-test-data) drive an app end to end on every PR. Neither one seeds the database it depends on. Both assume realistic, connected rows are already there: the agent that wrote the code and the agent that tests it each expect something else to have prepared the schema. That gap closes cleanly when the generator is both a CLI step and an [MCP](https://modelcontextprotocol.io/) tool. Seedfast exposes the same seed run over the Model Context Protocol, so an agent in Claude Code or Cursor calls `seedfast_run` to seed the branch database, then lets the tests (human-written or agent-driven) run against it. The seeded-data prerequisite collapses into one tool call. As more of the pipeline runs on agents, "the test data is generated, valid, and current" becomes the assumption everything else rests on. ## Seedfast as the CI seed step Seedfast runs as a single step after migrations: one command points at the database, reads the live schema, and writes connected rows. ```yaml # .github/workflows/test.yml (excerpt) - name: Seed test database run: npx seedfast seed --scope "realistic accounts, orders, and line items" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: ${{ secrets.TEST_DATABASE_URL }} ``` The `--scope` is plain English: Seedfast generates connected accounts, orders, and line items with valid foreign keys between them, sized to the test rather than to production. `--output json` returns a machine-readable result, and the command exits non-zero on failure so the build gate can read it. Add a table or column in a later migration and the next run picks it up, with nothing to reconfigure. The `SEEDFAST_API_KEY` comes from a free Seedfast account, and the [CI/CD database seeding](/docs/cicd-database-seeding) guide has the full [GitHub Actions](https://docs.github.com/en/actions) and GitLab CI setup, including per-environment keys and exit-code handling — the [service container and health-check mechanics underneath that setup](/blog/github-actions-seed-postgres-database) get their own walkthrough. For ephemeral databases this pairs with branch-per-PR workflows: the [Neon branching seed data](/blog/neon-branching-seed-data) guide covers seeding a fresh branch database per run, and [E2E test fixtures](/blog/e2e-test-fixtures) covers generating data for Playwright and Cypress. ## Choosing a generator for CI Not every test-data tool clears that bar. Web-based column generators like Mockaroo are built for clicking, so they don't drop into an unattended command. ORM-coupled seeders like Drizzle's and Prisma's `seed` scripts need your application code running and only work inside one framework. Production-copy tools like pg_dump hand back the privacy and size problems you came here to avoid, and masking platforms like Tonic Structural still start from a copy of production to de-identify it — the production access CI-time synthetic data skips entirely. Even Faker-based scripts generate columns in isolation, with no way to keep the rows connected. Seedfast takes a connection string, reads the live schema, and holds referential integrity with nobody watching. If you're cross-shopping on that axis, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison ranks tools on schema-awareness and CI fit, [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate) weighs a metered web agent against a flat CLI step, and the [data seeding tools](/blog/data-seeding-tools) guide covers the regulated-industry angle where copying production isn't allowed at all. For the broader strategy of where test data comes from and how it stays valid, see [test data management](/blog/test-data-management). ## Frequently asked questions ### What is synthetic data for CI/CD? Synthetic data for CI/CD is test data generated inside the pipeline from your database schema, after migrations apply, rather than copied from production or replayed from a static seed file. Because it's regenerated from the live schema each run, it stays valid as the schema changes, and because it's synthetic it carries no real customer data. ### How is generating data in CI different from a copied production dump? A copied dump is production-sized, so a multi-gigabyte restore can take minutes on every job, and it usually needs a masking step before it's safe to use. A generated seed is sized to the test and skips masking entirely, because there's no real data in it to mask. The dump also reflects whatever the schema looked like on the day it was taken, where Seedfast re-reads the current schema at the start of the run. ### Can a synthetic data generator keep referential integrity in CI? It has to, or the seed is useless. Seedfast writes parent and child rows whose references all resolve, including tables that point at each other in a cycle, so what lands in the CI database is referentially valid without anyone wiring the relationships by hand. ### Does seeding in CI send my data to an AI provider? No rows leave your database. Seedfast reads the schema's shape (table and column names, types, and constraints) to plan the data, then generates the values itself; your production records are never involved, because they aren't in the test database to begin with. If a table or column name is itself sensitive, confirm that path fits your policy before wiring it into CI. ### Does seeding on every pipeline run get expensive? It depends on the pricing model. A four-job matrix on a twenty-push day is eighty seed runs; a per-row or per-token generator bills all eighty, scaling with your commit rate. Seedfast sells a flat monthly plan that includes a pool of credits, and a run draws from that pool by how much data it generates, with no per-row or per-token meter underneath. Eighty runs on a 30-table schema is roughly $48 of credits, well inside Premium's $180, and the monthly bill itself doesn't move with your commit rate. ## Seed your CI database as a pipeline step [Seedfast](https://seedfa.st/) connects to your PostgreSQL database, reads the live schema, and generates connected, realistic data through a single CLI step or an MCP tool, fresh and referentially-valid on every pipeline run, with no copied production data and no `seed.sql` to patch. [Set up CI/CD seeding](/docs/cicd-database-seeding) or [run your first seed](/docs/seed-your-database) in about two minutes — the free plan doesn't ask for a card. See [pricing](/pricing) for the paid tiers. Related guides: - [CI/CD Database Seeding](/docs/cicd-database-seeding): the full GitHub Actions and GitLab CI setup - [Staging Without Production Data](/blog/staging-without-prod-data): generate instead of copy, for lower environments - [Neon Branching Seed Data](/blog/neon-branching-seed-data): seed a fresh branch database per run - [E2E Test Fixtures](/blog/e2e-test-fixtures): generate data for Playwright and Cypress runs --- # Best AI Test Data Generator: Tools Compared (2026) > Source: https://seedfa.st/blog/best-ai-test-data-generator > Last updated: 2026-09-18 An AI test data generator invents values for a database with a language model, and the ones worth shortlisting read your schema first, so the rows they write already satisfy its foreign keys. For application testing the best AI test data generator is one of those, and Seedfast works that way against a live Postgres database. Every product in this space advertises an AI feature now, so one search turns up a faker wrapper with a chat box, a column randomizer that accepts plain-English prompts, and a generator that reads a whole schema before it writes a row, all answering to the same three words. Where they part company is relational work. The table rates six of them on what decides whether a generator survives contact with a real schema. ## The best AI test data generator, six tools compared | Tool | Reads the live schema | Foreign keys | Output | Free tier | How you drive it | | ------------------------- | ------------------------------------ | ---------------------------------------------------------------- | ------------------------------ | ------------------------------------ | ------------------- | | **Seedfast** | Yes, on every run | Valid across tables | Rows written into the database | Free plan, no card | CLI, MCP | | **Mockaroo** | No, columns defined in a web form | Only between datasets built inside it | CSV, JSON, SQL and more | 1,000 rows per file (Jun 2026) | Web UI, REST API | | **Tonic Fabricate** | Yes, through its live connection | Valid across tables | Loads a database, plus exports | Monthly credits on signup (Jul 2026) | Web agent, SDK, API | | **drizzle-seed** | No, it reads a Drizzle schema object | Valid inside a Drizzle schema, cycles are a documented sore spot | Rows written into the database | Free, Apache-2.0 | TypeScript library | | **Faker and ORM seeders** | No | You wire them by hand | Values your own code inserts | Free, open source | Library | | **DataFiller** | No, it parses a schema file | Valid across tables | SQL, CSV, TSV | Free, GPLv3 | Command line | Two columns carry most of the decision. A generator that reads the live schema keeps working after a migration, while one fed a hand-written description keeps producing rows for the database you remember rather than the one you have. Foreign keys follow from the same property, since a child row can only point at a parent the generator already knows about. The rest is fit rather than correctness, which is why a web form you fill in by hand and a CLI that runs in a pipeline can both be right answers in the same week. Free tiers move, which is why the figures above carry the month they were checked and why no paid price appears on this page at all. The [test data generator pricing](/compare/test-data-tool-pricing) comparison keeps those dated numbers in one place. ## Free test data generation tools, open source included **DataFiller** is the closest thing here to a schema-aware generator you can run for nothing on a machine of your own. It parses a SQL schema file, reads the constraints out of it, and writes `INSERT` statements that keep foreign keys and unique columns valid, with directives in SQL comments where you want a particular column generated a particular way. The whole thing is one Python file under GPLv3, it takes a `--seed` option for reproducible runs, and it can target CSV and TSV as well as SQL. Check its activity before you depend on it, since the published source stands at 2.0.1-dev dated November 2022. **drizzle-seed** is the Drizzle team's own package, Apache-2.0, and the obvious first answer inside a Drizzle project. It reads the schema object you already import, generates deterministically from a fixed `seed` number, and fills text columns from a catalog rather than writing values that mean anything in your domain. The [drizzle-seed alternative](/compare/drizzle-seed-alternative) page covers where that stops being enough. **Faker** and the seeders built into Prisma, Laravel and other ORMs are libraries rather than generators. Faker returns a plausible value for a column you name, an ORM seeder gives you a typed way to insert rows, and the relational logic between them lives in code you re-edit after every migration. Two names still on open-source shortlists no longer belong there, since Snaplet Seed wound down in 2024 and Neosync was archived in August 2025, with the migrations off both covered on the [Snaplet Seed alternative](/compare/snaplet-seed-alternative) and [Neosync alternative](/compare/neosync-alternative) pages. Benerator CE does still read a database over JDBC under GPL v2 with exceptions, though its own README now calls the project maintenance only. ## Generators with an API or a CLI This is where a shortlist usually narrows, because a generator that only runs when somebody clicks Generate cannot run after a migration at two in the morning. Seedfast is driven from the command line and from AI agents over MCP. There is no public REST API. `seedfast connect` takes a connection string, `seedfast seed --scope "a sentence"` reads the live schema and writes the rows, and a `SEEDFAST_API_KEY` environment variable replaces the browser login wherever a pipeline runs unattended. The MCP path is that same run reached a different way, so an agent already open in your editor calls the seeding tool as part of whatever it was doing and reports the counts when the run finishes, instead of writing a throwaway insert script. The [MCP setup guide](/docs/mcp-setup-guide) has the configuration for each client, and [AI agent seeding](/docs/ai-agent-seeding) walks one real session call by call. Mockaroo publishes a REST API, which is usually what the phrase "API access" means in this category, and its free tier allowed 200 requests a day as of June 2026. What comes back is flat, one dataset at a time, unconnected to the database you intend to load it into. Tonic Fabricate reaches you through a web and chat agent with an SDK and an API behind it. Its documented CLI loads already-generated data into a target rather than reading your live schema, so a CI-first team integrates against the API instead of dropping in a command. The [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate) page is the full head-to-head. Among the free options, DataFiller runs as a command and pipes into `psql`, while drizzle-seed is a function call inside TypeScript you already maintain, so automating either means scheduling a script of your own. ## Why a schema-aware generator holds foreign keys All six tools produce values a person would believe, and they diverge on everything relational, which shows up as a sequence of events rather than as a missing checkbox. Inserting an `order_item` means already having an `order`, a `product` and a price row for it to reference. Read the schema and that ordering comes free, because the foreign keys spell it out and parent tables get filled before child tables without anyone saying so. Work instead from a column list somebody typed, and nothing in it says those tables are related, so the export lands as a pile of CSV files with the reconnecting left to you. Then a migration ships. The hand-maintained definition now describes a database that no longer exists, while the schema-aware run picks up the new column on its next pass with nothing to edit. [Seedfast](https://seedfa.st/) packages that as one command against a connection string: ```bash seedfast seed --scope "100 accounts with transactions and varied balances" → Connected to PostgreSQL → Found 34 tables, 67 foreign keys → Generating data... → Done. ``` The `Found 34 tables, 67 foreign keys` line is the approach in miniature. Seedfast reads that schema from the database on every run, infers from the table and column names what each table is holding, shows you the plan it intends to write before a single row lands, and then writes the rows directly into the tables rather than handing you a file to import. Your credentials stay on your machine, and only schema metadata is sent for generation. Scale follows the scope you describe, fifty rows for a local database or tens of millions for a load test. That fits a Postgres project on Supabase, Neon, RDS or plain PostgreSQL where the data has to come out relationally correct and the seed runs in CI or from an AI agent. It also draws a boundary, because Seedfast is Postgres-first and stays in the application-testing lane. MySQL, Oracle and SQL Server are not first-class targets, and it builds neither ML-training sets nor masked copies of production. For the Postgres field ranked tool against tool, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison lays them out. ## Why an LLM alone is not an AI test data generator Give a language model a single column to fill and it does the job beautifully, inventing a name, an email, a transaction amount that reads like it came off a real ledger. Trouble starts when those values have to agree with each other across the schema, when the `order` it just invented has to belong to a `user` that already exists, which belongs to an `account`, on down a foreign-key graph the model cannot take in at once. At that point the work stops being writing and turns into bookkeeping, and a model built to predict the next token has no special reason to keep the books straight. Underneath it is a plain context-window problem. The model has only what you pasted into the prompt, a forty-table schema with all its keys and constraints stops fitting in that window quickly, and by the time it reaches the bottom of the dependency chain it has lost track of what it set up at the top. What comes back inserts an `order` against a `user_id` nothing ever created, runs clean until it reaches that row, then falls over with half the tables full. Run it again and it collides with the rows the first attempt left behind. Reword the prompt and the breakage usually moves somewhere new, because none of the process is deterministic and every pass costs more tokens. Neon [ran this experiment in the open](https://neon.com/blog/vibe-coding-with-ai-to-generate-synthetic-data-part-1) and did not dress up the result, reporting that the models coped while the schema stayed shallow and grew less reliable as the foreign-key graph deepened. What a schema-aware generator does instead is hand the model only the part it is good at. Seedfast sends your plain-English scope and the schema metadata to the model, which writes the values, while the job of making every row reference something that exists stays in ordinary deterministic code. For the specifics of what leaves your machine, [data handling and privacy](/privacy-policy) has them. One more split is worth naming here, since application test data and model-training data share a label and little else. Gretel and MOSTLY AI answer to statistical fidelity, which matters for training a model and does nothing for a forty-table Postgres schema that has to insert cleanly, so a reader in that lane wants [where Gretel users go after the NVIDIA acquisition](/compare/gretel-alternative) rather than this page. ## Buyer questions about test data generators ### Where can I buy a test data generator with API access? Mockaroo sells the closest thing to what that question usually means, a REST endpoint you call for a dataset, priced by rows. Tonic Fabricate sells an SDK and an API around its generation agent. Seedfast does not sell a public REST API at all, and the honest reason is that the surface it was built for is different. A CLI against a connection string, plus an MCP tool an agent can call, covers the pipeline case and the editor case without anyone writing an HTTP client, so the API you would have bought is replaced by a command in your CI config. ### Which test data generator has the most reliable user reviews? Seedfast has no G2 or Capterra listing as of September 2026, so there is no review count here to point you at. A star rating in this category mostly records how long a vendor has been selling through procurement, and it says little about what happens when the tool meets your schema. What you can check yourself in an afternoon is worth more. Read the [docs](/docs/seed-your-database), then point a generator at a scratch database and count what lands. ### What is the best data generator for testing applications? For a relational application database, it is one that reads the schema and writes connected rows, which on the table above means Seedfast, Tonic Fabricate, or drizzle-seed if the project is already a Drizzle project. Flat, single-table mock data is a different question with an easier answer, and Mockaroo or Faker will cover it in minutes. The dividing line is whether anything in your test suite follows a foreign key. Once it does, a column generator leaves you writing the reconnection logic that the generator was supposed to remove. ### What are the top tools for generating test data? Six of them sit in the table above with their tradeoffs in the cells, which beats a ranking, because the top tool for a Drizzle app and the top tool for a forty-table billing schema in CI are not the same tool. Sort instead by what has to be true when the run finishes. Needing rows in a live database with valid keys narrows the field to the schema-aware three, while a file you can hand to a colleague opens it back up to the web tools and the libraries. For the method-by-method background under all of them, [test data generation](/blog/test-data-generation) is the reference. ### Can test data generators create edge cases like nulls safely? They can, within what the schema permits. A nullable column takes nulls at whatever rate a generator lets you set, which is how a `deleted_at` stays empty on most rows and present on the rest. A `NOT NULL` column is a different matter, since the database refuses the row and no generator talks it out of that. The genuinely nasty cases are still worth writing by hand as a small fixture set alongside the generated bulk, among them a negative balance where your code assumed positive and a unicode name that breaks a report. ## Frequently asked questions ### Which AI can I use to fake data? Any capable language model will write convincing values, so the question that decides the outcome is what holds the structure together. Prompting a chat model directly works for one table and degrades as the schema grows. A schema-aware generator with a model inside it keeps the relational bookkeeping in code and uses the model only for the values, which is how Seedfast handles a live Postgres schema. ### How is an AI test data generator different from prompting ChatGPT for data? The difference is where the relational bookkeeping lives. Prompt a chat model and it hands back values, then leaves you to reconcile foreign keys, insert order and constraints across tables by hand, and it forgets your schema between sessions. Seedfast reads the live database instead, keeps the relationships in deterministic code rather than in a context window, and produces rows where every reference stays valid. The model still writes the values, it just stops being responsible for the structure. ### Can an AI coding agent generate test data on its own? It produces plausible values easily enough, and relational data trips it up, because once the foreign-key graph runs more than a few tables deep the agent loses the insert order and leaves the database half-seeded. The pattern that holds is to hand it a generator over MCP, where one call reads the schema and returns a filled database, so the agent delegates the relational work instead of scripting inserts and then colliding with its own half-finished run. The [generate test data with AI](/blog/generate-test-data-with-ai) playbook covers the setup, and the same gap appears one layer up in [agentic QA tools that write and run the tests themselves](/blog/agentic-qa-test-data). ### Are there open source test data generation tools? Several exist and they have different shapes. DataFiller parses a schema file and emits FK-valid inserts under GPLv3, while drizzle-seed generates deterministically from a Drizzle schema object under Apache-2.0. Faker and the ORM seeders are free libraries where the relational logic stays yours. Benerator CE still reads a database over JDBC, though its maintainers now describe the project as maintenance only. None of them reads a live Postgres schema and writes realistic domain values into it in one command, which is the gap the commercial tools in this category sell into. ### Can AI generate realistic dummy data for dashboard mockups? For a dashboard it mostly works, with one caveat about totals. Weighted status values and a skewed distribution across foreign keys give you a customer list where a few names carry a dozen orders and the rest carry one, which is what a screenshot needs. Column-by-column generation does not compute `orders.total` from the line items underneath it, though, so the numbers agree on a chart and disagree the moment somebody in the demo clicks into a single order. The [demo data generator](/blog/demo-data-generator) guide goes further into that difference. ### Is AI-generated synthetic test data safe for software testing? Yes, when it is relationally valid and generated rather than copied. Synthetic test data for software testing has to satisfy foreign keys, unique constraints and insert order, or the app fails before a test runs, which is the bar Seedfast clears by reading the live schema and a flat value generator misses by never looking at one. The compliance edge follows from the same design, since rows generated from a schema map to no real person and leave no production PII to mask or leak. Weigh one caveat against that, which is that schema-aware LLM tools, Seedfast included, send schema metadata to a model provider to generate values, so read a vendor's data-handling terms if the table and column names themselves are sensitive. The [data seeding tools](/blog/data-seeding-tools) guide covers the compliance side. ## The same job on a real database A generator you paste a schema into stops at the edge of the tab. The row counts get set by hand, the file is carried back out and loaded somewhere, and the whole sequence repeats after the next migration. For a database that already exists, the best AI test data generator is the one that skips that loop. [Seedfast](https://seedfa.st/) connects to the database itself, reads the schema as it stands that morning, and writes the rows into the tables where your application will look for them, either as one command locally or as one step in a pipeline. The free plan asks for no card, so a first run against a scratch database is a short detour rather than a purchase. [Create an account](/register), point it at a schema you already have, and read the rows it writes before you decide anything. Related guides: - [Generate Test Data with AI](/blog/generate-test-data-with-ai): the playbook for prompting an AI agent to seed - [Synthetic Test Data Generation](/blog/synthetic-test-data-generation): the process behind the tools on this page - [Test Data Generator Pricing](/compare/test-data-tool-pricing): the dated price comparison for this category - [Data Seeding Tools for Regulated Teams](/blog/data-seeding-tools): the regulated-industry compliance angle --- # Best Postgres Test Data Generator: How to Choose in 2026 > Source: https://seedfa.st/blog/best-postgres-test-data-generator > Last updated: 2026-09-18 What separates the best Postgres test data generator from the rest isn't glamorous, and it's rarely the thing comparison tables lead with. The tool that wins keeps your foreign keys valid without supervision, reading the live schema and staying correct after a migration that would have broken a hand-written script. Row caps and column-type counts, the numbers most rankings open on, barely bear on that. Think about the schema you actually have to fill — twenty-odd tables, a foreign-key graph that long ago outgrew the whiteboard, and a Friday deadline to have it full of data for local dev and CI. Faking a few users is the easy part. Everything hard about it is relational, because an order has to point at a customer who exists and a line item at an order that does, and a single row that references nothing kills the whole insert. So this guide ranks the tools that generate test data for a live PostgreSQL database by how much of that relational work they take off your hands. Prefer to hand-roll the SQL yourself? The [PostgreSQL seed-data cookbook](/blog/test-data-postgresql) is that route. ## What is the best Postgres test data generator? The best Postgres test data generator is a **schema-aware** one, which on Postgres means a CLI that reads the live schema instead of a definition you maintain by hand. Seedfast re-reads that schema on every run and inserts referentially valid rows off it, so a database that keeps changing under you never leaves the seed a step behind. Tonic Fabricate takes the same schema-aware route through a web agent, and the comparison below puts both against the desktop generators and the ORM seeders. ## How to choose a Postgres test data generator Most "best generator" lists rank tools by row caps and column types, which are the easiest things to measure and, in a real Postgres project, among the least useful. What decides whether a tool survives contact with your actual schema is a shorter and harder set of questions, and they're the ones the rest of this section works through. Start with **schema-awareness**, because everything else follows from it. A tool that reads your actual tables, columns, types, and constraints produces data that fits the database as it stands today. Make it guess from a description you maintain by hand, and the output only ever fits your memory of the schema, which drifts the moment someone ships a migration. Foreign-key validity then comes almost for free. Inserting an `order_item` means already having an `order`, a `product`, and a `price` for it to point at, so a generator has to keep every row [referentially valid](/blog/referential-integrity) as it writes it, and cope with the tables that point back at themselves through [circular foreign keys](/blog/circular-foreign-key-seed), which don't always have a clean answer. Skip that and you get rows that fail on insert or orphans that quietly poison the next test run. The other half of the decision is operational. Test data earns its keep when it regenerates with nobody watching — [fresh synthetic rows on every pipeline run](/blog/synthetic-data-ci-cd), landing right after a migration and before the suite. A web UI you click through by hand can't do that; a CLI that takes a connection string drops straight into [CI/CD database seeding](/docs/cicd-database-seeding). Maintenance is the same story stretched over a year. A seed plan or a set of hand-written column definitions breaks on the next `ALTER TABLE` and waits for someone to patch it. Re-read the schema on every run and the change just flows through. Across a year of weekly migrations, that difference is most of the total cost. Two smaller things round it out. Generated values should be skewed the way real data is skewed, with a few customers placing most of the orders and amounts clustering around common prices, because dashboards and business logic behave differently against realistic data than against uniform noise, which is [why small test datasets lie](/blog/small-data-big-lies). And the pricing model matters more than it looks once the generator runs on every CI build. A flat plan doesn't move all year. Per-row caps and per-token meters do, which is how a routine pipeline run becomes a number nobody can forecast at budget time. Seedfast bills a flat monthly plan that includes a pool of credits, and a run across a 30-table schema draws roughly sixty cents of that pool, which is a number you can multiply by your build count before the quarter starts. ## Postgres test data generator comparison The criteria above, scored across the main tools. "Schema-aware (live DB)" means the tool connects to a running database and reads the current schema itself, not that you paste or describe it. For the cross-use-case view, [compare every test-data tool side by side](/compare) on the hub. | Tool | Schema-aware (live DB) | Auto FK / referential integrity | CLI / CI-native | Maintenance after migration | Beyond Postgres | Pricing model | | -------------------------- | ----------------------------------- | ---------------------------------------------- | -------------------------------------------------- | --------------------------------- | ------------------------------------- | --------------------------------------------------------------- | | **Seedfast** | Yes — reads live Postgres every run | Yes — valid, connected data | Yes — CLI + MCP | Low — re-reads schema, no script | No — Postgres only | Free plan, then $16 or $69/mo in credits | | **Tonic Fabricate** | Yes — has live DB connectivity | Yes — schema-aware generation | Web/chat agent + SDK/API; no one-command seed step | Re-runs in the web agent | Yes — MySQL, Oracle, Databricks, more | Free / $29/mo Plus + pay-as-you-go token usage (as of Jul 2026) | | **Mockaroo** | No — define columns in web form | Manual — own web schema only, not your live DB | Export-then-import; no live DB | Manual — redefine columns | Export formats only | Free up to ~1,000 rows (Jun 2026); paid above | | **dbForge Data Generator** | Yes — connects to Postgres | Yes — respects FK constraints | Cross-platform GUI IDE; has a CLI | Manual — configure in the GUI | Separate product per engine | from ~$139.95/yr (Pro edition, as of Jun 2026) | | **Datanamic / EMS** | Yes — desktop GUI generators | Yes — FK-aware | Desktop GUI | Manual — GUI-driven | Multiple engines | Paid — per-seat commercial license | | **Faker / ORM seeders** | No — you read the schema | Manual — you wire FKs by hand | Library, runs anywhere | High — every migration edits code | DB-agnostic values | Free | One column runs against Seedfast on purpose. It's Postgres-only, and when your work spans several engines that's a genuine limit worth stating up front. Below, each row of the table gets its honest detail. ## Seedfast [Seedfast](https://seedfa.st/) is a CLI and [MCP](/docs/mcp-setup-guide) tool that connects to a live PostgreSQL database, reads the schema on every run, and generates relational data from a plain-English scope: ```bash seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" ``` It generates directly from the schema, and the rows come out connected and valid. Tables that loop back on themselves it handles too, [circular references](/blog/circular-foreign-key-seed) included, which is where a hand-ordered script tends to stall. Because the schema is read fresh each time, a migration that adds a `NOT NULL` column, or a whole new table, is picked up on the next run with nothing to edit. The same command runs as one step in a pipeline, and over MCP (`seedfast_run`) an AI agent such as [Claude Code](/blog/claude-code-mcp-database-seeding), Cursor, or Codex CLI can seed the database directly instead of writing a throwaway script. Real usage backs up the width claim. Since November 2025, 325 distinct Postgres schemas have run through Seedfast, with a median of 14 tables and a quarter of them at 24 tables or wider. The widest single schema on record carries 432 tables, and rows generated across that whole set have crossed 651 million. That count comes from Seedfast's internal run records, measured on 2026-09-10. It fits best when you're on Postgres (plain PG, Supabase, Neon, or RDS) and you want valid relational data from the live schema, generated in CI or from inside an AI agent, with a flat monthly bill instead of a per-token meter and no seed script to keep alive. That focus cuts both ways. SQL Server, MySQL, and Oracle aren't first-class targets, and because it builds rows from your schema rather than masking real production data, a de-identified copy of production is a different category of tool you'd reach elsewhere for. The free plan needs no card and never expires, and no plan caps tables or seeds; the paid tiers run $16 or $69 a month. That is enough to [run your first seed](/docs/seed-your-database) in a couple of minutes before you decide, or watch the [one-command demo](/#demo) and read the [pricing](/pricing) tiers first. ## Tonic Fabricate Tonic Fabricate is Tonic.ai's synthetic-data product, and it's genuinely schema-aware, generating relational data from scratch without touching production, the same core approach Seedfast takes. It can also connect to a live database to model generation on a real schema. They part on the developer surface. Fabricate is a web and chat agent (the Data Agent) with an SDK and API; its documented CLI loads already-generated data into a Postgres target rather than reading your live schema and seeding it as a pipeline step. So the seed-after-migration loop runs through the web agent or an API integration, not a one-command CLI you drop into CI. Fabricate's pricing runs on credits rather than a flat plan. A free tier comes with **$5/month in credits** if you sign up with a personal email, or **$10/month in credits** plus every model with a work email, the **Plus** plan at **$29/month** includes **$25/month in credits**, and past that you're on **pay-as-you-go**, roughly **$0.17 per standard turn** and **$0.37 per complex turn**, per Tonic's pricing as of July 2026 (those rates move, so re-check before budgeting). On database breadth, Fabricate clearly beats Seedfast. It generates into and exports to MySQL, Oracle, Databricks, and Snowflake, plus formats like Parquet and Avro, none of which a Postgres-only tool comes near. It suits teams that want a polished web or chat generation agent, work across several database engines, or are content to drive generation through the UI or an API. The catch for a CI-first team is that there's no CLI or MCP step reading your live schema and seeding it in one command, so the pipeline path runs through an API or web-agent integration, and the per-turn metering layered on the monthly plan makes any single run's cost hard to predict ahead of time. The [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate) page is the full head-to-head. ## Mockaroo Building a dataset in Mockaroo means defining fields column by column in a web form, then exporting them as CSV, JSON, or SQL. For one-off, single-table datasets it's quick, and it doubles as a REST mock-API tool. The ceiling is structural, because it doesn't connect to your live database. It can reference between datasets you build inside its own web form, through the Dataset column type, but it can't read or honor your existing schema's foreign keys, so a real relational database means exporting each table and then resolving the relationships and insert order yourself. The free tier caps generation at 1,000 rows per file (Mockaroo's plans page, as of June 2026). For the relational, CLI-driven case, the [Mockaroo alternative](/compare/mockaroo-alternative) comparison digs into the gap. ## dbForge Data Generator dbForge Data Generator is the data-generation feature inside **dbForge Studio for PostgreSQL** (Devart), a cross-platform database IDE for Windows, macOS, and Linux. It's mature and FK-aware, and its Professional edition — from around **$139.95/year** on Devart's ordering page as of June 2026 — bundles both the data generator and a command-line interface, so it can run unattended in CI. What sets it apart from a focused seeding tool is its shape. It's a heavyweight desktop IDE where you configure generation rules per column inside a project, closer to a full workbench than to the one-command CLI that reads a live schema and generates from a plain-English scope. The free Express and lower Standard editions leave the data generator out, and there's no MCP integration for AI coding agents. ## Datanamic and EMS In the same lineage as dbForge sit Datanamic and EMS, long-standing desktop GUI generators — FK-aware, but built for a click-through workflow rather than a command. Teams that would rather configure generation by hand in a GUI use them for exactly that. Both are commercial and sold per seat, so it's worth checking each vendor's site for current Postgres parity and pricing before you commit. ## Faker and ORM seeders Faker and the seeders built into Prisma, Drizzle, Laravel, and other ORMs are libraries, not generators in the schema-aware sense. Faker produces a plausible value for a column you name (`faker.person.fullName()`); an ORM seeder gives you a typed API to insert rows. Neither one reads your schema or keeps the relationships valid on its own, so you end up writing the insert logic and wiring the foreign keys yourself, then re-editing that code every time a migration touches a seeded table. Of the bunch, Drizzle's `drizzle-seed` comes closest to schema-aware; the [drizzle-seed alternative](/compare/drizzle-seed-alternative) comparison covers where it fits and where a live-schema CLI takes over. These belong in a seed file as building blocks, the deterministic reference data and the roles and feature flags that must line up exactly across environments, rather than as the generator for a large relational schema. For a methods-level breakdown see [test data generation](/blog/test-data-generation), and for an ORM-by-ORM command reference, the [database seeder comparison](/blog/database-seeder). ## What about Snaplet Seed and Neosync? Two tools used to fill the open-source, schema-aware slot and still turn up on older lists, but both are gone now. **Snaplet Seed** wound down in 2024 and its library has stalled since; if you're still running it, [migrating off](/compare/snaplet-seed-alternative) is the move. As for **Neosync**, a 2025 acquisition sent it to the archive, and [the way out](/compare/neosync-alternative) is covered separately. Either way, a "best Postgres generator" list that still recommends one of them as a live option hasn't been updated. ## Which one should you pick? The recommendation depends on where the data has to land. If you're on Postgres and don't want to babysit a seed script through every migration, [Seedfast](/docs/seed-your-database) re-reads the live schema on every run and rides through the changes, landing relational data a column-level generator has no way to produce. For that same schema-aware generation through a web or chat agent, across several database engines, or without a one-command CLI step, Tonic Fabricate is the closest fit, provided you budget for the per-turn metering on top of its monthly plan. The lighter cases sort themselves out. Flat, single-table mock data or a mock API is Mockaroo's job, quick and free up to its row cap, with the foreign keys left to you. A team that would rather configure generation in a desktop GUI has dbForge Studio or Datanamic, and dbForge even ships a CLI in its Professional edition, though you're driving a full IDE rather than a focused one-command step. On a tiny, stable schema, Faker or an ORM seeder is genuinely enough. When production copies are off the table for compliance reasons, the [data seeding tools](/blog/data-seeding-tools) comparison covers that angle, with stack-specific walkthroughs for [seeding a Supabase database](/blog/supabase-db-seed) and [seeding a Neon database](/blog/seed-neon-database). ## Frequently asked questions ### How is a Postgres test data generator different from copying production data? A generator builds rows from your schema, so it needs no access to production and carries no real customer data, nothing to mask and nothing to leak. A production copy (via `pg_dump` or a clone) gives you real distributions but drags real PII into every environment it lands in, which is exactly what regulated teams can't allow. The [data seeding tools](/blog/data-seeding-tools) guide covers the compliance side. ### What makes a test data generator postgres-ready? A Postgres-ready generator reads the live schema and writes rows where every child row references a parent that exists. Seedfast connects over an ordinary connection string and runs headless in CI against managed hosts like Supabase, Neon, and RDS, with no desktop GUI anywhere in the loop. Circular foreign keys it handles too, a cycle that's `NOT NULL` on both sides included, so a table that loops back into another table comes out valid without hand-written constraint handling. ### What is a schema-aware test data generator? In practice it means you never hand-write what the columns are, because the tool learns that from the database and generates rows that already fit. Change the schema and the next run just follows, with no column list or seed file to patch by hand, which is exactly the chore a non-schema-aware tool leaves you holding. Seedfast reads that schema over a connection string on every CLI or MCP call; Tonic Fabricate reads one too, through a web and chat agent with an SDK and API behind it. ### Can a postgres database seeding tool run in CI/CD? Yes, if it's a CLI that takes a connection string. A web-UI generator can't run unattended, while Seedfast runs as one pipeline step after migrations and before the test suite, re-reading the live schema each time so it adapts to whatever migration just landed, down to a column it has never seen before. The [CI/CD database seeding guide](/docs/cicd-database-seeding) walks through the pipeline setup. ### Do I need a generator, or is Faker enough? Faker is plenty for flat tables, throwaway prototypes, and the handful of rows a unit test needs. It stops being enough the moment foreign keys span more than a few related tables. Faker will happily generate the values, but it doesn't read your schema or resolve the relationships, which leaves you hand-wiring the insert order and re-editing it on every migration. Past that point Seedfast does the relational work Faker drops on your desk, reading the schema itself so the wiring never becomes a file you own. ### Is there a free Postgres test data generator? A few, each with a catch. Faker and ORM seeders are free, but you wire the foreign keys yourself. generatedata.com does free flat web generation with nothing relational underneath, and Mockaroo's free tier stops at 1,000 rows per file. Seedfast has a permanent free plan (no card, $5 of credits a month, tables and seeds uncapped), enough to test a live-schema, FK-aware run end to end before any of the paid plans from $16 a month. ## Seed connected data from your live schema The whole point of a schema-aware tool is that foreign keys stop being your problem. Point [Seedfast](https://seedfa.st/) at a live PostgreSQL database, and one command fills it with connected, realistic rows, with no seed script left behind to rot on the next migration. Trying it costs nothing and needs no card, so [run your first seed](/docs/seed-your-database) against your own schema, or look over [pricing](/pricing) first. Related guides: - [Data Seeding Tools for Regulated Teams](/blog/data-seeding-tools): the category-level comparison with the compliance angle - [Database Seeder: ORM Built-ins vs Standalone Tools](/blog/database-seeder): the per-ORM seeder command reference - [Snaplet Seed Alternative](/compare/snaplet-seed-alternative) and [Neosync Alternative](/compare/neosync-alternative): migrating off the discontinued schema-aware tools - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql): the hand-rolled SQL route when you'd rather not use a tool - [Best AI test data generator](/blog/best-ai-test-data-generator): the approach comparison for the MCP-era workflow, from raw prompts to schema-aware generation - [Tonic Fabricate vs Mockaroo vs Seedfast](/compare/tonic-fabricate-vs-mockaroo): the three-way head-to-head on schema-awareness and workflow - [GenRocket Alternative](/compare/genrocket-alternative) and [Mockaroo Alternative](/compare/mockaroo-alternative): the enterprise-TDM and column-generator head-to-heads --- # Compliant Test Data: Why Generated Beats Masked > Source: https://seedfa.st/blog/compliant-test-data > Last updated: 2026-08-19 A **compliant test data tool** builds rows from your database schema rather than copying production, so no personal data reaches a lower environment for anyone to mask in the first place. Seedfast works that way on Postgres. It reads table and column definitions off a connection string and invents every value it writes, leaving the production rows untouched. That is what puts development and staging outside GDPR scope rather than merely shrinking what they expose, and it retires both the masking pipeline and the audit questions about how much of the schema that pipeline actually covered. If you work somewhere regulated, you already know the line "we need realistic test data" is not a lawful basis for parking real customer records in a dev database. The usual response is to mask: copy production, scrub the columns that matter, and keep the scrubbing rules in step with a schema that never stops moving. There is a less brittle option. Generate the rows from the schema and there is nothing to scrub in the first place, because no value in the dataset ever belonged to a real person — and that single shift, from copying to constructing, is what the rest of this guide unpacks under GDPR and at the point of choosing a tool. ## Key Takeaways - "Compliant" hinges on one question: was the data ever personal? GDPR Recital 26 puts truly anonymous, never-personal data outside scope, while pseudonymised (masked) data is still personal data and stays in scope. - Generating from the schema removes the exposure at its source rather than reducing it. Seedfast reads only schema metadata, never a production row, and invents every value it writes, so nothing in a lower environment ever belonged to a person and no masking rules have to be kept in step with the schema. ## Why "compliant" hinges on whether the data was ever personal Under GDPR, whether test data counts as "compliant" comes down to a single distinction in [Recital 26](https://gdpr-info.eu/recitals/no-26/), and the two halves of that recital draw it cleanly. Recital 26 says the principles of data protection "should therefore not apply to anonymous information, namely information which does not relate to an identified or identifiable natural person or to personal data rendered anonymous in such a manner that the data subject is not or no longer identifiable." Generated test data that never derived from a real person is the cleanest case of "does not relate to an identified or identifiable natural person." There is no data subject behind the row. Pseudonymised data is the mirror image. Masking a production export — tokenising names, scrambling emails — is pseudonymisation, and Recital 26 addresses it head-on: "Personal data which have undergone pseudonymisation, which could be attributed to a natural person by the use of additional information should be considered to be information on an identifiable natural person." Because the original stays recoverable with the right additional information, the masked copy remains personal data, and every environment holding it stays in scope. That distinction is really the [data minimization](https://gdpr-info.eu/art-5-gdpr/) principle in disguise. GDPR Article 5 says personal data should be held to what is necessary, and a full production copy sitting in a lower environment is the opposite of necessary — masking shrinks the exposure, but the copy is still real data you have to govern. Generating the rows removes the personal data altogether, which is about as minimal as a dataset gets. ## Two approaches, side by side For a regulated team, the realistic choices come down to two: mask a production copy, or generate the data from the schema. Everything else is a variation on one of those, and they sit on opposite sides of the one line that actually matters here — whether a real person's data is ever present in the lower environment at all. | Approach | Production access | PII in the output | In GDPR scope | Pipeline to maintain | | --- | --- | --- | --- | --- | | Mask / anonymize production | **Required** | Pseudonymised (still personal) | Yes (pseudonymised data is in scope) | Per-column masking rules, refreshed on schema change | | Generate from schema | Not required | None (never personal) | No (never-personal data is out of scope) | None; reads the live schema each run | **Masking production data** is a real, established approach. Tonic Structural connects to your production database and applies masking, deterministic tokenization, and format-preserving encryption while preserving referential integrity; Delphix pairs irreversible masking with data virtualization for non-production environments, and K2View uses an entity-based model with format-preserving tokens (as of June 2026). Their shared strength is fidelity — the output inherits the real structure, distributions, and edge cases of production, because it *is* your production data, transformed. The cost is a production connection plus the security review it triggers, a masking pipeline to update on every schema change, and output that, being pseudonymised, remains personal data under Recital 26. **Generating from the schema** works the other way around. Seedfast reads the structure (tables, columns, constraints, foreign keys) and builds valid relational rows from nothing, so no row ever began as a real person's record and there is nothing in the output anyone could re-identify. That trade suits a team whose goal is keeping dev and staging out of scope more than mirroring production exactly. Where Faker, ORM seeders, and the rest of the field fit is mapped in the [data seeding tools](/blog/data-seeding-tools) comparison. Masking wins when testing depends on the *exact* distributions and edge cases of production — fraud-model validation, or analytics that has to match prod. Generated data is realistic and correctly shaped, though it isn't your actual users' data. For development, CI, and demos, generation is the compliant default. ## What to look for in a compliant test data tool Once you've settled on generating over masking, the tool still has to deliver it. These are the criteria that decide whether it keeps you out of scope and out of maintenance: - **No production access.** The tool should never need a connection to your production database. If it does, you're back to a pipeline that processes real data and needs a security review. - **Schema-aware, foreign-key-level.** It should read the live schema and generate data with valid relationships across every table, rather than plausible values sitting in isolated cells. Column-level tools like Faker produce values but leave [referential integrity](/blog/referential-integrity) for you to wire up by hand. - **No masking rules to maintain.** Generation from the live schema adapts to migrations automatically. A masking config, by contrast, drifts the moment someone adds a PII column nobody flagged. - **CLI/CI-native.** Compliant data you can only produce by hand isn't sustainable. The tool should run as a step in your pipeline so every ephemeral environment gets fresh, compliant data without a person in the loop. - **Audit-defensible provenance.** "Our dev and staging environments do not contain customer data; we generate it from the schema" is a clean answer for an auditor. "We copy production and run a masking script" invites questions about the script's coverage. ## Where Seedfast fits [Seedfast](https://seedfa.st/) is a CLI that points at a live Postgres database and generates relational data from a plain-language scope, re-reading the schema on every run. Only the metadata is ever read — table and column names, types, foreign keys, never the rows — so there's no production access to security-review and no masking step to maintain, because nothing in the pipeline was real to begin with. ```bash seedfast seed --scope "B2B SaaS app with 5,000 accounts, users, and 90 days of activity" ``` ``` → Connected to PostgreSQL → Found 28 tables, 54 foreign keys → Generating data... → Done. Seeded 41,200 rows in 9.1s ``` From there, Seedfast generates rows so that every child row points at a parent that actually exists, handling looped and self-referencing tables along the way. The values it produces are domain-shaped: plausible names, amounts in sensible ranges, distributions that don't look machine-stamped. And when next sprint's migration adds a table, the following run just picks it up, with no masking config left encoding last week's schema and quietly drifting out of date. Treat Seedfast as a technical control and nothing grander. By building data fresh, the CLI keeps production PII out of lower environments — real value, but only one line item in a program that still rests on scoping, access control, and audit logging. It is not a certification, and on its own it makes you neither GDPR nor SOC 2 compliant. If your program requires a signed BAA or a SOC 2 report from a sub-processor, check Seedfast's current status directly; don't infer it from this page, and keep treating the tool as one control among the rest. One data-path detail belongs in that same sub-processor review: to generate the data, Seedfast sends the schema definition — table and column names, types, constraints — to an AI provider, while the row values themselves never leave your database. If a column name is itself sensitive, make sure that path fits your policy. None of this is legal advice; for your own obligations, talk to qualified counsel. For where this is purely a Postgres-stack decision, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison covers the wider tool field. ## The regulated lenses The same generate-from-schema logic shows up under each framework, with a different name on the obligation. **GDPR.** Under GDPR, never-personal data is outside scope (Recital 26), and copying production into lower environments works against data minimization ([Article 5](https://gdpr-info.eu/art-5-gdpr/)). Generating from the schema satisfies minimization for non-production environments by removing the personal data entirely. **SOC 2.** A Type II audit asks where your test data comes from, and reducing PII in dev and staging supports the confidentiality and privacy criteria an auditor evaluates ([AICPA SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2)). "We generate it from the schema" is a cleaner control narrative than documenting a masking pipeline's coverage. **HIPAA and PCI.** The same construction keeps the test-data path clear of protected health information and cardholder data; the framework-specific mechanics live in [HIPAA compliant test data](/blog/hipaa-test-data) (§164.514 de-identification) and [test data for fintech](/blog/test-data-for-fintech) (PCI DSS). For the staging-specific case of dropping the production copy, see [staging without production data](/blog/staging-without-prod-data). ## Frequently asked questions ### Is GDPR test data the same as anonymized data? Anonymized and generated data get conflated, but they are different under GDPR. Anonymized data starts as real personal data and is processed to strip identifiers; under [GDPR Recital 26](https://gdpr-info.eu/recitals/no-26/) it counts as only pseudonymised — and still personal data — if it can be re-identified with additional information. Data that was generated and never personal has nothing to re-identify, which is the cleaner footing for gdpr test data. ### How do I make test data GDPR-safe? The most defensible route to gdpr-safe test data generation is to build the rows from your schema instead of copying and masking production. Seedfast reads your tables, columns, and foreign keys, then builds realistic relational rows from that structure alone, so no personal data enters the pipeline at any point. Before committing to one approach, the [data seeding tools](/blog/data-seeding-tools) guide weighs Faker, ORM seeders, masking, and schema-aware generation side by side, which is the concrete next step this page doesn't otherwise spell out. ### Does masking make data compliant? On its own, no. Masked or tokenized production data is pseudonymisation under Recital 26, so it stays personal data and its environment stays in scope, and you still carry a production connection plus the pipeline behind it. Masking earns its place on fidelity, so choose it when a test genuinely depends on production's real distributions, like fraud-model validation. For development, CI, and demos, generation is the answer, and Seedfast produces that data from the schema on the connection you give it. The comparison table above shows which side of the scope line each one lands on. ### Is generated test data personal data under GDPR? Generally not, provided the generator never touches production and the output cannot be linked back to real individuals. Data built from a schema describes no actual person, because no data subject stands behind the row, which puts it under Recital 26's anonymous-information exception rather than the pseudonymisation rule. The catch worth checking is provenance. If a generator quietly reads production rows to "learn" distributions, that assumption breaks; Seedfast never opens one, taking table and column definitions and leaving the contents alone. ### Does a compliant test data tool make my whole system compliant? Compliance is not something a single tool confers. Seedfast removes one specific risk, production PII in non-production environments, by building the rows from schema metadata alone, and that supports principles like data minimization. Test data compliance is one piece of a wider picture that also covers scoping, access controls, and audit logging. Generating from the schema is a strong control inside that program, and it doesn't stand in for the rest of it. ## Related guides - [Data seeding tools](/blog/data-seeding-tools): the regulated-team comparison of every approach, from Faker to masking to schema-aware generation. - [HIPAA compliant test data](/blog/hipaa-test-data): the healthcare-regulation lens, covering §164.514 de-identification and keeping dev and staging out of HIPAA scope. - [Test data for fintech](/blog/test-data-for-fintech): the financial-services and PCI lens on the same generate-from-schema approach. - [Staging without production data](/blog/staging-without-prod-data): the staging-specific case for generating instead of cloning production. - [Get started](/docs/seed-your-database): point [Seedfast](https://seedfa.st/) at your Postgres schema and generate compliant test data in one command, no production access. See [pricing](/pricing) or the [one-command demo](/#demo). --- # Test Data for Fintech: Realistic Accounts Without Touching Production > Source: https://seedfa.st/blog/test-data-for-fintech > Last updated: 2026-07-09 **Test data for fintech** is realistic, relational data for a financial-services database (accounts, transactions, ledger entries, KYC records, cards) with no real customer information in it. Three things make it hard at the same time: production is off-limits because of PII and cardholder data under PCI DSS, the balances have to reconcile, and the money columns can't lose precision. Generate the rows straight from your schema and the compliance half of the problem goes away, because there's nothing real to mask when nothing real was copied. If you build on a financial-services Postgres schema, you already know the wall. Compliance won't let production data leave production, and a `pg_dump` onto a developer's laptop is an audit finding waiting to happen. The data you actually need is the awkward kind: coherent across `accounts`, `transactions`, and `ledger_entries`, with balances that sum correctly and amounts stored as exact numerics. This page walks through what that data has to look like, the two compliant ways to produce it, and how Seedfast collapses the second of them into one command run against your own schema. ## Key Takeaways - Fintech test data has to satisfy three constraints at once: no production PII or cardholder data (PCI DSS), ledgers that reconcile (double-entry nets to zero), and exact monetary precision (`numeric`, never `float`). - Two compliant paths exist. Anonymize a production copy by masking the cardholder fields, which needs production access. Or generate from the schema, where nothing is copied so there's nothing to mask. - The hard part of a financial schema was never fake names. It's the foreign-key chain (`account → transactions → ledger_entries`), double-entry consistency, and balance distributions realistic enough to actually exercise reconciliation logic. - Generating from the schema keeps real account and card data out of dev and test entirely, rather than putting a masked copy of it there. - Because Seedfast builds from the schema rather than from a copy, the `customers → accounts → transactions → ledger_entries` chain arrives connected with no production database anywhere in the run, and nothing lands that would need masking afterwards. ## Why fintech teams can't use production data In a fintech system, the production database holds the two categories regulators care about most: personally identifiable information (names, addresses, government IDs pulled in during KYC) and cardholder data. [PCI DSS](https://www.pcisecuritystandards.org/) Requirement 3 governs the second. A stored Primary Account Number (PAN) has to be rendered unreadable through hashing, truncation, tokenization, or strong cryptography, and when it's displayed it can show at most the first six and last four digits ([PCI Security Standards Council](https://www.pcisecuritystandards.org/)). A raw card number sitting in a developer's local database is the precise thing that standard exists to prevent. So "just copy prod into staging" isn't the shortcut it looks like. The moment real cardholder data or KYC records land in a lower environment, that environment inherits obligations it was never built to carry, and what looked like a time-saver turns into a compliance event. The same logic that pushes regulated teams toward [compliant test data](/blog/compliant-test-data) applies with extra force here: once card data is in scope, the cheapest move is to keep it out of dev and test altogether. What a fintech team actually needs, then, is data that behaves like production (accounts with balances, transactions with realistic amounts, a ledger that reconciles) without a single real record in it. That's something you generate, not something you copy and scrub clean. ## What a fintech schema actually needs The naive view of test data is "fake names and emails." For a payments backend, names are the easy part. The hard part is the relational and numerical shape of the data. A simplified financial-services schema looks something like this: ```sql CREATE TABLE accounts ( id uuid PRIMARY KEY, customer_id uuid NOT NULL REFERENCES customers(id), currency text NOT NULL, -- ISO 4217, e.g. 'USD' balance numeric(19,4) NOT NULL, -- exact, never float status text NOT NULL, -- active / frozen / closed opened_at timestamptz NOT NULL ); CREATE TABLE transactions ( id uuid PRIMARY KEY, account_id uuid NOT NULL REFERENCES accounts(id), type text NOT NULL, -- debit / credit / transfer amount numeric(19,4) NOT NULL, status text NOT NULL, -- pending / posted / reversed created_at timestamptz NOT NULL ); CREATE TABLE ledger_entries ( id uuid PRIMARY KEY, transaction_id uuid NOT NULL REFERENCES transactions(id), account_id uuid NOT NULL REFERENCES accounts(id), direction text NOT NULL, -- debit / credit amount numeric(19,4) NOT NULL, posted_at timestamptz NOT NULL ); ``` Four properties make this data hard to fake by hand: - **FK chains have to resolve in order.** A `ledger_entry` needs a `transaction`, which needs an `account`, which needs a `customer`. Insert them out of order and the run fails on a foreign key violation. Keeping [referential integrity](/blog/referential-integrity) intact means inserting in dependency order, which is trivial for four tables and a real problem at thirty. - **Money is exact, not approximate.** Balances and amounts live in `numeric`, not `float`. Test data that rounds or drifts will pass a smoke test and then hide rounding bugs that only surface in reconciliation. - **Double-entry has to balance.** For a ledger, every posted transaction produces matched debit and credit entries that net to zero. Random amounts in `ledger_entries` produce a ledger that never reconciles, so the reconciliation code path never actually runs against your test data. - **Distributions have to be plausible.** Real account balances are skewed: most accounts hold modest amounts, a few hold a lot. A handful of transactions get reversed or flagged. Uniform random values miss every edge case your fraud, limits, and reporting logic was written to catch. This is the gap between [generic test data generation](/blog/test-data-generation) and data shaped to a financial domain. Column-level tools fill the cells. They don't make the ledger reconcile. ## The two compliant ways to get fintech test data With production copies off the table, two compliant paths remain, and they split on one question: is any real record ever touched? You can anonymize a production copy — connect to prod and mask or tokenize the PAN and KYC fields, which needs a live production connection, a security review, and a masking pipeline that still processes real cardholder data on the way through. Or you can generate from the schema, reading tables, columns, and foreign keys to build rows from nothing, so dev and test never hold cardholder data at all. The masking lane is where Tonic Structural, Delphix, and K2View sit, behind enterprise contracts and quote-based pricing. It still earns its place when a test genuinely depends on your exact production distributions — validating a fraud model against real history, or analytics that has to match prod to the decimal — because that data started life as the real thing, and generated rows, though realistic and correctly shaped, are not your customers' actual history. For development, integration tests, CI, and demos, generation is the compliant default. The full masking-versus-generation comparison, with the vendor breakdown and the scope table, lives in [compliant test data](/blog/compliant-test-data); this page stays on what a financial schema specifically demands. For this domain, one term is worth pinning down: "synthetic data for fintech testing" sometimes means ML-training corpora, a separate world. Here it means dev, test, and demo data for your application database — the rows that fill your schema so integration tests have something real-shaped to run against, not a corpus for training a model. ## Test data for fintech without copying production Here is the generate-from-schema path in practice. Seedfast is a CLI that connects to a live Postgres database, reads the schema on every run, and generates relational data from a plain-language scope. It reads metadata (table and column names, types, foreign keys), not the rows. There's no production data in the loop because none is ever read. ```bash seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" ``` ``` → Connected to PostgreSQL → Found 34 tables, 67 foreign keys → Generating data... → Done. Seeded 12,847 rows in 6.3s ``` Seedfast returns the `customers → accounts → transactions → ledger_entries` chain fully connected, including the loops that make a hand-written seed impossible, as long as the schema leaves one side of a loop nullable or deferrable. The values it writes are domain-shaped: balances in a skewed distribution, amounts as exact numerics, and a mix of posted, pending, and reversed transactions. When next sprint's migration adds a `cards` or `kyc_documents` table, the following run just picks it up. No seed script is sitting there encoding last week's schema, waiting to break. It's worth being precise about what Seedfast doesn't touch. It never connects to production, and it doesn't mask or de-identify anything, because there's nothing to de-identify when the data is built from scratch. One detail does belong in any vendor review: to generate the data, the schema definition (table and column names, types, constraints) is sent to an AI provider. The row values never leave your database. But a table or column name can itself be sensitive, so treat that path like any other sub-processor and run it past your security lead. None of this makes you PCI compliant, and Seedfast doesn't claim to. What it does is take real cardholder data out of the test-data path by never copying it in the first place; the attestation, scoping, and controls stay yours to own. The practical change is that your dev and test databases stop being somewhere a card number could leak from, because there was never one there to leak. If you're choosing a tool rather than an approach, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison covers the wider field, and the [GenRocket alternative](/compare/genrocket-alternative) guide covers the down-market, self-serve path if an enterprise vendor has already quoted you for this job. ## What bad test data actually costs There's a number on this. IBM put the global average cost of a data breach at $4.44 million in 2025, and $10.22 million in the United States ([IBM Cost of a Data Breach Report 2025](https://www.ibm.com/reports/data-breach)). A copy of production cardholder data sitting in a staging environment is exactly the kind of exposure behind that figure. On the same spreadsheet as a breach like that, keeping real card data out of dev and test is about the cheapest control a fintech team can buy. ## Frequently asked questions ### Is generated fintech data PCI compliant test data? It contains no real cardholder data (no real PAN, no real account holder) because it's built from your schema instead of copied from production. That keeps card data out of the environment, which is what makes it [compliant test data by construction](/blog/compliant-test-data). It does not, on its own, make you PCI compliant: compliance is a property of your entire environment, scope, and controls, and the attestation stays with your team. Generation handles one piece of it, getting real card data out of the test-data path. ### What's the difference between synthetic data for fintech testing and masking? Masking starts from real production records and transforms the sensitive fields, so it needs a production connection and a pipeline that handles real cardholder data on the way through, even though the output is de-identified. Synthetic generation starts from the schema, so no row was ever real and nothing in the pipeline is. The tradeoff is fidelity versus cleanliness: masking reproduces your exact production distributions, while generation hands you a dataset with nothing real in it. ### Does generated fintech test data contain any real cardholder data? No. Seedfast reads the schema (tables, columns, constraints, foreign keys) and never the rows in production. The values it writes fit your column formats, card-shaped and account-shaped, but they map back to no real person or card. Nothing real goes in, so nothing real comes out. ### Will the ledger actually reconcile? It can, once the transaction-to-ledger relationship is part of what gets generated rather than an afterthought. Seedfast takes that requirement from the scope, so a run asked for balanced double-entry postings writes matched debit and credit `ledger_entries` against each transaction, with exact `numeric` amounts so balances don't drift. Column-level tools that emit one random value per cell won't: they fill `ledger_entries` with independent random amounts that never sum to zero. ### Can fintech teams use production data for testing at all? Usually not without consequences. Once real PII or cardholder data is in scope, copying the production database into development can put a team in breach of PCI DSS rather than save it time. Teams under these rules need data that was never real in the first place: either anonymized production copies (masking, with a production connection) or data generated from the schema with no card data in the pipeline. ## Related guides - [Compliant test data](/blog/compliant-test-data): the broader GDPR / SOC 2 / compliance view of generating test data that no regulator can object to. - [Test data for healthcare apps](/blog/test-data-for-healthcare): the same problem in the other heavily regulated domain, covering temporal patterns, FK chains, and zero PHI. - [HIPAA test data](/blog/hipaa-test-data): the healthcare-regulation analog of this page's PCI angle. - [Data seeding tools](/blog/data-seeding-tools): the full spectrum of approaches, from Faker to masking to schema-aware generation, and when each fits. - [Get started with Seedfast](/docs/seed-your-database): point it at your Postgres schema, describe the fintech scenario you need, and seed dev or CI without a production connection. Start on the [free plan](/pricing), or watch the [one-command demo](/#demo). --- # Prisma Postgres Seed: prisma db seed, psql, and the Direct URL > Source: https://seedfa.st/blog/prisma-postgres-seed > Last updated: 2026-06-26 _Prisma Postgres seed in one command — or rewrite your seed script every time the schema changes. Prisma's managed Postgres spins up an empty database in seconds; then you have to fill it. Here are three ways to do it without fighting the connection string._ > **TL;DR.** To seed a Prisma Postgres database, run `npx prisma db seed` against the **direct** connection string (`db.prisma.io`, not `pooled.db.prisma.io`). Configure `seed: "tsx prisma/seed.ts"` in `prisma.config.ts` and point `directUrl` at the direct URL in `schema.prisma`. In Prisma ORM v7, `migrate dev` no longer triggers the seed — invoke `prisma db seed` explicitly. For schemas past ~15 related tables, [Seedfast](https://seedfa.st/) reads the live schema on each run and skips the seed file entirely. ## Key Takeaways - Prisma Postgres ships with **two TCP connection strings** — direct (`db.prisma.io:5432`) and pooled (`pooled.db.prisma.io:5432`). Always seed against the **direct** URL. The pooled endpoint runs through a connection pooler and breaks long transactions and prepared statements, the same class of failure Neon and Supabase users hit on their poolers - For edge runtimes that can't open a TCP socket (Cloudflare Workers, Vercel Edge), the `@prisma/adapter-ppg` package — exporting `PrismaPostgresAdapter` — wraps Prisma's serverless driver and accepts the same direct TCP connection string. For Node-side seeds the plain TCP URL with the standard `pg` driver is simpler - `prisma db seed` still works — but in Prisma ORM v7 it no longer runs automatically during `prisma migrate dev`. You have to invoke it explicitly, or call it from your CI step - A fresh Prisma Postgres database has no production rows to copy and no PII to anonymize, which leaves an empty schema waiting for realistic test data. Developers in fintech and healthcare start every greenfield project from exactly that position - When the schema changes — which it will — hand-written seed files break. [Seedfast](https://seedfa.st/) reads the live schema on every run and regenerates valid, connected data, so there's no `seed.ts` to maintain **Quick fix if you landed here from a broken seed:** ```bash # Pooled URL — breaks seeding (transaction-mode pooler, no prepared statements) postgres://USER:PASS@pooled.db.prisma.io:5432/?sslmode=require # Direct URL — use this for seeds, migrations, admin scripts postgres://USER:PASS@db.prisma.io:5432/?sslmode=require ``` The rest of this guide walks through `psql`, `prisma db seed`, and the `@prisma/adapter-ppg` serverless-driver path that actually work against Prisma Postgres, and shows how to keep the dataset alive across migrations. You provisioned Prisma Postgres because it spins up instantly, scales to zero, and ships with the tooling you already use — `prisma migrate`, the Prisma Client, the new `create-prisma` scaffold. But the database is empty, and the only realistic data you can copy in from somewhere is production data — which, if you work in a regulated industry, you're not allowed to touch. To seed a Prisma Postgres database, you need three things: the right connection string, a seed strategy that survives schema changes, and a way to make the data look like the real thing without ever leaving development. This guide covers all three. If you want the framework-agnostic version of this article first, [how to seed a database](/blog/seed-database) covers the cross-stack fundamentals. This article is specifically about Prisma Postgres — the managed product, not the ORM seed pattern. ## Why a Prisma Postgres database needs seeding A fresh Prisma Postgres database is empty. Migrations create the schema; nothing fills it. Unlike a long-lived shared dev database that accumulates data over months, a Prisma Postgres project is born clean and stays clean until you put something in it. Three scenarios force the seeding question: 1. **New project onboarding.** A teammate clones the repo, runs `npx prisma migrate dev`, and ends up with thirty empty tables. The app boots but every list view is blank. 2. **CI and ephemeral environments.** Each CI run, each preview deploy, each rebuilt local stack starts from zero. Without a seed step, end-to-end tests hit empty queries and fail in ways that have nothing to do with the change being tested. 3. **Demos and staging.** You need 500 products, a year of order history, and users that look real. Faker-style random columns aren't good enough — the data has to hold up under a 10-minute customer demo. Prisma's own docs cover the **mechanics** of `prisma db seed` — how to wire up `prisma.config.ts`, how to run TypeScript seeds with `tsx`. They don't cover the **lifecycle problem**: the seed file gets stale on every migration, and the team that wrote it hasn't touched it in six weeks. That's the part that bites. ## Prisma Postgres seed connection strings: direct vs pooled Every Prisma Postgres database exposes two TCP connection strings. The dashboard shows both: ```bash # Direct (one-to-one, full Postgres protocol, prepared statements work) postgres://USER:PASS@db.prisma.io:5432/?sslmode=require # Pooled (transaction-mode pooler, optimized for short-lived requests) postgres://USER:PASS@pooled.db.prisma.io:5432/?sslmode=require ``` The difference is the `pooled.` subdomain. The pooled endpoint sits behind a transaction-mode connection pooler — every query gets a fresh backend connection, which is great for serverless apps with bursty traffic, and bad for everything else. Specifically: - Prepared statements don't survive across transactions, so any client that prepares a query (most of them) will hit `prepared statement "s1" already exists` after the first reuse - Long-running operations like a multi-statement seed transaction can be terminated when the pool decides to recycle the backend - Session-level settings (`SET`, advisory locks, temporary tables that span statements) are left behind on a backend you no longer hold; [the PgBouncer transaction mode matrix](/blog/pgbouncer-transaction-mode) measures what happens to each of them there None of that makes the pooled endpoint wrong for the app runtime, where the open question is how many connections each instance should hold, a number [the connection pool size calculator](/tools/postgres-connection-pool-calculator) works out from the compute you run on and the worker count in front of it. For seeding, migrations, and any admin script, use the **direct** URL. Wire it up explicitly: ```bash # .env.local DATABASE_URL="postgres://USER:PASS@pooled.db.prisma.io:5432/?sslmode=require" # app runtime DIRECT_URL="postgres://USER:PASS@db.prisma.io:5432/?sslmode=require" # migrations + seeds ``` Substituting your own credentials is where a password containing `@` breaks the URI, because the parser splits at the last one it finds and reports a host that does not exist. Percent-encode it first, or let [the connection string builder](/tools/postgres-connection-string-builder) do the encoding. In `schema.prisma`, point `directUrl` at the direct URL — Prisma uses it for migrations automatically: ```prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") } ``` If a multi-statement seed dies halfway through with `prepared statement "s1" already exists` or `cached plan must not change result type`, the script is talking to the pooler. Point it at the direct URL and re-run. You may also see the older Prisma Accelerate–style URL — `prisma+postgres://accelerate.prisma-data.net/?api_key=...` — referenced in some examples. That URL is part of the Accelerate caching layer, not the standard Prisma Postgres connection. The two TCP strings above are what the Prisma Console gives you for a Prisma Postgres database, and they cover every seeding scenario this guide describes. ## Method 1: Prisma Postgres seed with raw SQL The simplest path. Write `INSERT`s, run them with `psql`. ```sql -- seed.sql INSERT INTO organizations (id, slug, plan) VALUES (1, 'acme-corp', 'team'), (2, 'beta-labs', 'free') ON CONFLICT (id) DO NOTHING; INSERT INTO projects (id, organization_id, name, status) VALUES (1, 1, 'Mobile rewrite', 'active'), (2, 1, 'Billing refactor', 'planned'), (3, 2, 'Internal demo', 'active') ON CONFLICT (id) DO NOTHING; ``` ```bash psql "$DIRECT_URL" -f seed.sql ``` SSL is mandatory on Prisma Postgres — the `sslmode=require` parameter on the connection string handles that automatically. Drop it and you'll get `connection requires SSL`. For larger reference loads, `COPY FROM STDIN` is meaningfully faster than row-by-row inserts because it skips per-row parsing and planning: ```bash psql "$DIRECT_URL" -c "COPY products (name, price, category_id) FROM STDIN CSV" < products.csv ``` `ON CONFLICT DO NOTHING` keeps the seed idempotent — CI can run it twice without falling over on duplicate keys. For values that should always reflect the latest state, use `ON CONFLICT DO UPDATE`: ```sql INSERT INTO feature_flags (key, enabled) VALUES ('new_checkout', true) ON CONFLICT (key) DO UPDATE SET enabled = EXCLUDED.enabled; ``` Raw SQL is fine for **reference data** — feature flags, country codes, role definitions, the dozen rows your app reads at boot. It starts breaking once you have ten or fifteen tables with foreign keys, because every migration that adds a `NOT NULL` column or a new FK forces you to hand-edit the seed. [Seed file maintenance](/blog/seed-file-maintenance) covers why this lifecycle is so brutal on active codebases. ## Method 2: Seed Prisma Postgres with prisma db seed Most teams using Prisma Postgres are also using Prisma ORM, so the natural path is `prisma db seed`. In current Prisma versions, the seed command is configured in `prisma.config.ts`: ```typescript // prisma.config.ts import { defineConfig, env } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", seed: "tsx prisma/seed.ts", }, datasource: { url: env("DIRECT_URL") }, }); ``` A typical TypeScript seed against Prisma Postgres looks like any other Prisma seed — point the client at the direct URL and use the standard API: ```typescript // prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const org = await prisma.organization.upsert({ where: { slug: "acme-corp" }, update: {}, create: { slug: "acme-corp", plan: "team" }, }); await prisma.project.upsert({ where: { organizationId_name: { organizationId: org.id, name: "Mobile rewrite" } }, update: {}, create: { organizationId: org.id, name: "Mobile rewrite", status: "active" }, }); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect()); ``` ```bash npx prisma db seed ``` Two things changed in Prisma ORM v7 that catch teams off guard: - `prisma migrate dev` no longer runs `prisma generate` or the seed automatically. The `--skip-seed` flag is gone because there's nothing to skip — you have to invoke `prisma db seed` explicitly when you want it - `prisma migrate reset` still triggers the seed, so the "wipe and restart" workflow is intact If you want the seed to run after every migration locally, wrap it in a `package.json` script: ```json { "scripts": { "db:reset": "prisma migrate reset --force", "db:migrate": "prisma migrate dev && prisma db seed" } } ``` ### Seeding from an edge runtime with `@prisma/adapter-ppg` Most seeds run in Node — locally or in CI — and the plain TCP direct URL is the right choice there. If the seed has to run from an environment that can't open a TCP socket (a Cloudflare Worker, a Vercel Edge function, a managed worker without raw networking), use Prisma's serverless driver adapter: ```bash npm install @prisma/adapter-ppg ``` ```typescript // prisma/seed.ts import { PrismaClient } from "@prisma/client"; import { PrismaPostgresAdapter } from "@prisma/adapter-ppg"; const adapter = new PrismaPostgresAdapter({ connectionString: process.env.DIRECT_URL!, }); const prisma = new PrismaClient({ adapter }); async function main() { await prisma.organization.upsert({ where: { slug: "acme-corp" }, update: {}, create: { slug: "acme-corp", plan: "team" }, }); } main().finally(() => prisma.$disconnect()); ``` The adapter takes the same direct TCP connection string and routes queries over Prisma's HTTP/WebSocket transport, so the seed runs anywhere the Prisma Client runs — but for a regular Node script, plain TCP without the adapter is one fewer dependency. The bigger problem with both `prisma db seed` paths is the same one raw SQL has: the values are hand-written. When a migration adds `accounts.organization_id NOT NULL`, the seed breaks on the next run. Someone — usually whoever picked up the on-call rotation — has to fix it before the rest of the team can boot the app. If that lifecycle sounds like every Monday morning on your team, [Seedfast](https://seedfa.st/) skips the seed file entirely — generating the dataset from the live schema on every run. The next section covers how it fits into a Prisma Postgres workflow. ## Method 3: Schema-aware seeding with Seedfast Point Seedfast at the Prisma Postgres direct URL, describe the dataset in plain English, and Seedfast reads the schema — tables, columns, constraints, foreign keys — and generates a valid, connected dataset that fits it. There's no `seed.sql` or `seed.ts` in the repo, because the schema is the seed plan. ```bash npm install -g seedfast # or: brew install seedfast-ai/tap/seedfast # Log in and connect — paste the direct (db.prisma.io) connection string seedfast connect # Generate data from the current schema seedfast seed --scope "two organizations on the team plan, 8 active projects, varied task assignments" ``` The next migration adds a column? Seedfast reads it on the following run and generates the new field. The next migration adds a whole table? Same story, picked up on the next run. Every row comes out valid and connected, self-referencing tables included, and a pair of tables that reference each other works wherever the schema leaves one side of the loop nullable, so there's no hand-written FK list to maintain. The scope changes per environment, the command doesn't: ```bash # Local dev loop seedfast seed --scope "2 organizations, 4 users each, 5 projects between them" # Preview branch for a PR that touches the billing flow seedfast seed --scope "1 organization mid-trial with overdue invoice and 3 retried payments" # Sales demo seedfast seed --scope "500 organizations across 3 plan tiers, 6 months of project history" ``` Run `prisma migrate deploy` first to make sure the database is on the latest schema, then run Seedfast against the same direct URL. Prisma owns the structure; Seedfast fills it. For production reference data — feature flags, country codes, admin roles — keep the versioned SQL or the Prisma seed. That data belongs to the application and should ship through migrations. Seedfast targets the larger, evolving development, CI, staging, and demo datasets that go stale every time the schema moves — not the dozen rows that go to prod. ### Why this matters in regulated industries For teams that **can't** copy production data into dev — fintech, healthcare, anyone under HIPAA, GDPR, or SOC 2 review — Seedfast removes the blocker that hand-written seeds and anonymization pipelines both leave in place: - **Seedfast generates from the schema, not a snapshot.** No production access, no PII pipeline, no anonymization step. Schema metadata (table and column shapes) is sent over the wire to Seedfast's generation service so the model knows what to build; row values are not. The result is realistic test data without ever pulling production rows out of their environment. For the wider workflow, see [staging without production data](/blog/staging-without-prod-data) and the [HIPAA test data](/blog/hipaa-test-data) playbook. - **Seedfast fills a database, not a column.** Faker hands back random strings. Seedfast generates connected organizations, accounts, transactions, and audit rows with valid foreign keys, distributions that look like a real product, and values that pass a domain check. - **Seedfast scales with one command.** The same `seedfast seed` runs ten rows for a unit test and half a million for a load test — only the `--scope` changes. The free plan covers small dev datasets and doesn't ask for a credit card. [Connect and run your first seed](/docs/seed-your-database) in about two minutes, or check [pricing](/pricing) before sending it to your CTO. ## Common Prisma Postgres seeding issues and how to fix them ### `prepared statement "s1" already exists` You're seeding through the pooled URL (`pooled.db.prisma.io`). The transaction-mode pooler discards prepared statements between transactions, and the driver tries to reuse one that's gone. Switch to the direct URL for the seed. ### `cached plan must not change result type` A server-side Postgres plan cache error: a prepared statement's result type changed underneath it, usually because a migration ran between two seed invocations on the same connection. Run migrations to completion before the seed, and use the direct URL — the pooler can mask this error in confusing ways. ### `connection requires SSL` Your connection string is missing `sslmode=require`. Prisma Postgres rejects non-SSL connections. Add the parameter, or set `PGSSLMODE=require` in the environment. ### `the URL must start with the protocol postgresql:// or postgres://` A `prisma+postgres://...` URL slipped into `DIRECT_URL`. The Prisma Console issues TCP strings (`db.prisma.io` / `pooled.db.prisma.io`) for Prisma Postgres — use those for `psql`, the `pg` driver, and `prisma db seed` without an adapter. The Prisma-protocol URL is not part of the standard seeding path. ### `prisma db seed` does nothing during `prisma migrate dev` This is the Prisma ORM v7 behavior change. `migrate dev` no longer auto-seeds. Either invoke `prisma db seed` explicitly, or chain it in a script: `prisma migrate dev && prisma db seed`. ### `relation "users" does not exist` The seed ran before the migration. The order is always **migrate then seed**, never the reverse. In CI, make `prisma migrate deploy` (or `prisma migrate reset --force` on ephemeral branches) a hard prerequisite of the seed step. ### Seed succeeds locally, fails in CI Two recurring causes: CI uses the pooled URL because that's what the app uses (switch to the direct URL for the seed step), or the CI environment hasn't run migrations against the freshly-created database (run `prisma migrate deploy` before the seed). ## Manual SQL vs prisma db seed vs Seedfast | Aspect | Raw SQL (`psql -f`) | `prisma db seed` | Seedfast | | ------------------------------------- | ----------------------------------- | ------------------------------------ | -------------------------------------------------------------------------- | | Setup time | None | Already there if you use Prisma | `npm install -g seedfast` | | External dependency | None — `psql` is everywhere | None — already in your stack | Separate CLI plus a network call to Seedfast's generation service | | File you maintain | `seed.sql` | `seed.ts` | None — reads the live schema | | FK order | Manual | Manual | Automatic | | Survives migrations | No — manual updates on every change | No — manual updates on every change | Yes — regenerates from the live schema | | Realistic volumes | Painful past ~50 rows | Works with Faker, still hand-written | Natural-language scope describes the dataset | | Output stability | Deterministic — committed values | Deterministic if you use fixed seeds | Regenerated each run; pin a seed value when you need byte-stable fixtures | | Reviewable in PRs | Yes — diff the SQL | Yes — diff the seed script | Indirectly — the scope is reviewable, individual rows are not | | Good for static config (flags, codes) | Excellent — versioned, reviewed | Excellent — versioned, reviewed | Not the target | | Good for dev / CI / staging datasets | Manual re-sync on every migration | Manual re-sync on every migration | Regenerates from the live schema | | What leaves your environment | Nothing — runs locally | Nothing — runs locally | Schema metadata only (table and column shapes); no row data is transmitted | Pick by job, not by religion. Reference data, the dozen rows the app reads at boot, belongs in a committed SQL file or a small `prisma db seed` script that goes through code review with the schema change. Seedfast targets the larger, evolving dev / CI / staging datasets that go stale on every migration, where regenerating from the schema is faster than fixing the file. [Try it on your Prisma Postgres schema](/docs/seed-your-database) in about two minutes, on a free plan that starts without a card. ## Using @prisma/adapter-neon with local Postgres for development **`@prisma/adapter-neon` is not required for local Postgres development. Use the default Prisma client with a regular `DATABASE_URL`, or `@prisma/adapter-pg` if you want a consistent driver-adapter API across environments.** The `@prisma/adapter-neon` package exists to solve one specific problem: edge runtimes like Vercel Edge Functions and Cloudflare Workers can't open raw TCP sockets, so Prisma can't talk to Postgres the normal way. The adapter wraps Neon's serverless driver, which routes queries over HTTP and WebSockets to a Neon-hosted endpoint. None of that machinery applies when your code runs in plain Node against a local Postgres container — Node has TCP, the standard Prisma client connects directly, and there's no Neon endpoint to point the adapter at. For the common setup — Neon in production on the edge, plain Postgres in Docker locally — drop the adapter in development. Construct `new PrismaClient()` with no `adapter` argument, set `DATABASE_URL` to a regular `postgresql://...` string, and Prisma will talk to your local container over TCP. The Neon adapter only gets instantiated in production where it's actually needed. The trade-offs between the three options: | Setup | Wire protocol | Where it runs | Use when | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------ | | Default `new PrismaClient()` | TCP (Prisma's built-in driver) | Node only | Local dev, seed scripts, any Node server | | `@prisma/adapter-pg` (Prisma driver-adapters docs) | TCP (wraps `node-postgres`) | Node only | You want the same driver-adapter API everywhere and locally use plain Postgres | | `@prisma/adapter-neon` | HTTP / WebSocket (wraps Neon's serverless driver) | Edge (Vercel Edge, Cloudflare Workers) | You deploy to an edge runtime against Neon | If you'd rather keep one code path across environments — same constructor, same generic shape — use `@prisma/adapter-pg` locally. It wraps `node-postgres` (the standard `pg` driver) and presents the same driver-adapter API as the Neon variant, so the only thing that changes between local and production is which adapter you import. Driver adapters reached GA in Prisma 6.16; on 6.16+, the `previewFeatures = ["driverAdapters"]` flag in the `generator client` block is no longer required. On older versions, add it to `schema.prisma`. ### Conditional adapter: @prisma/adapter-pg locally, @prisma/adapter-neon in production **Branch on `NODE_ENV` inside the factory that constructs `PrismaClient`: pass `PrismaPg` locally and `PrismaNeon` in production. The connection string and the query code stay identical; only the imported adapter changes.** ```typescript // lib/prisma.ts import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaNeon } from "@prisma/adapter-neon"; function makeClient() { const connectionString = process.env.DATABASE_URL!; if (process.env.NODE_ENV === "production") { const adapter = new PrismaNeon({ connectionString }); return new PrismaClient({ adapter }); } const adapter = new PrismaPg({ connectionString }); return new PrismaClient({ adapter }); } export const prisma = makeClient(); ``` `schema.prisma` needs the preview feature flag on Prisma versions where driver adapters aren't GA yet: ```prisma generator client { provider = "prisma-client-js" previewFeatures = ["driverAdapters"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") } ``` The simpler alternative is to skip the adapter locally entirely. `new PrismaClient()` with no adapter argument talks to local Postgres over plain TCP — nothing extra to install, configure, or fall out of sync. ### DATABASE_URL format for local Postgres with Prisma **For local Postgres with Prisma, `DATABASE_URL` is `postgresql://postgres:postgres@localhost:5432/postgres` — plain TCP, no SSL, no `sslmode=require`.** Replace user, password, and database name with whatever your container actually uses; both `postgresql://` and `postgres://` scheme prefixes work. ```bash # .env.local DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" ``` A few things that catch people: - `@prisma/adapter-neon` will not work against this URL. The adapter expects a Neon HTTP/WebSocket endpoint (something like `postgresql://USER:PASS@ep-xxx.region.aws.neon.tech/dbname`), and `localhost` resolves to a plain Postgres server that doesn't speak Neon's protocol. If you point the Neon adapter at `localhost:5432`, you'll get connection errors that look like protocol mismatches, not "wrong port" — because the bytes on the wire are different. - If `schema.prisma` declares `directUrl`, set it to the same local URL during development. Prisma uses `directUrl` for migrations and `prisma db seed`, so leaving it pointed at a production Neon endpoint while `DATABASE_URL` is local will run migrations against the wrong database the moment you forget which terminal you're in. - `sslmode=require` is for hosted Postgres (Neon, Prisma Postgres, Supabase, RDS). A local container doesn't have SSL configured by default, so leave `sslmode` off entirely or set it to `disable` if your client insists on a value. For the seed step specifically, the local DATABASE_URL is what `prisma db seed` reads when it constructs the client. No adapter, no `directUrl` gymnastics — just point at the container and run. If the schema is past a dozen tables and the hand-written seed file is what's slowing the local loop down, [Seedfast](/docs/seed-your-database) reads the live schema and generates the dataset directly against the local URL, so the development database fills up without a `seed.ts` to maintain. ## Frequently asked questions ### Do I need @prisma/adapter-neon for local Postgres development? No. The Neon adapter exists for edge runtimes (Vercel Edge, Cloudflare Workers) that can't open TCP sockets. Local Node has TCP, so the default Prisma client connects to a local Postgres container directly. Use `new PrismaClient()` with no `adapter` argument and a regular `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres`. ### Can I use @prisma/adapter-neon with localhost? Not in any useful way. The adapter expects Neon's HTTP/WebSocket endpoint (`*.neon.tech`), and a local Postgres container doesn't speak that protocol — pointing the adapter at `localhost:5432` produces protocol errors, not connection-refused errors. For local development, either drop the adapter and use the default client, or use `@prisma/adapter-pg` which wraps `node-postgres` and works against local TCP. ### How do I switch between @prisma/adapter-pg locally and @prisma/adapter-neon in production? Branch on `NODE_ENV` (or a dedicated env var) inside the factory that creates `PrismaClient`. Import both adapters, pick the right constructor based on environment, and pass the resulting adapter to `new PrismaClient({ adapter })`. The connection string can be the same env var (`DATABASE_URL`); only the adapter import differs. Driver adapters are GA on Prisma 6.16+; on older versions, add `previewFeatures = ["driverAdapters"]` to the `generator client` block in `schema.prisma`. ### What DATABASE_URL should I use for local Postgres with Prisma? A plain TCP URL with no SSL: `postgresql://postgres:postgres@localhost:5432/postgres` for the default Docker image, or whatever user, password, and database your container is configured with. Skip `sslmode=require` — that's only for hosted Postgres (Neon, Prisma Postgres, Supabase). If `schema.prisma` has a `directUrl`, point it at the same local URL during development so migrations and seeds don't accidentally hit production. ### How do I seed a Prisma Postgres database from the command line? Copy the **direct** connection string from the Prisma Console (the one with `db.prisma.io`, not `pooled.db.prisma.io`) and run `psql "$DIRECT_URL" -f seed.sql`. Make sure `sslmode=require` is on the URL. For Prisma projects, configure `seed: "tsx prisma/seed.ts"` in `prisma.config.ts` and run `npx prisma db seed`. For schemas with many tables, `seedfast seed --scope "..."` generates connected data without writing a seed file. ### Should I use the pooled or direct Prisma Postgres URL for seeding? Use the **direct** URL (`db.prisma.io:5432`). The pooled URL routes through a transaction-mode connection pooler that breaks prepared statements and can interrupt long seed transactions. Reserve the pooled URL for the application at runtime, where short, bursty queries benefit from the pool. ### Can I seed Prisma Postgres from a Cloudflare Worker or Vercel Edge? Yes — install `@prisma/adapter-ppg`, pass a `PrismaPostgresAdapter` instance to the `PrismaClient` constructor, and the seed runs over Prisma's HTTP/WebSocket transport. The adapter takes the same direct TCP connection string from the Prisma Console; you don't need a separate URL. For seeds running on Node (CI, locally), the plain TCP path with `pg` is simpler and one fewer dependency. ### How do I seed Prisma Postgres in GitHub Actions? Set `DIRECT_URL` as a repository secret pointing at the direct connection string. In the workflow, run `npx prisma migrate deploy` against `DIRECT_URL`, then run your seed step (either `npx prisma db seed` or `seedfast seed --scope "..."`). For application steps in the same job, use the pooled URL via `DATABASE_URL`. The general pattern is covered in [CI/CD database seeding](/docs/cicd-database-seeding). ### How do I seed Prisma Postgres without writing a seed file? Connect Seedfast to the database (`seedfast connect`, paste the direct URL) and run `seedfast seed --scope ""`. Seedfast reads the live schema and generates a valid, connected dataset every time, so when migrations add columns or tables, the next seed run reflects the new shape automatically — there's no file that goes stale. See [database seeder tools compared](/blog/database-seeder) for how this fits next to the framework-built-in seeders. ### Can I use Prisma Postgres for staging without copying production data? Yes — that's the typical setup for teams in regulated industries. Stand up a separate Prisma Postgres database for staging, run migrations against it, then seed it with realistic generated data. No production rows leave the production environment, and the dev/staging path doesn't depend on an anonymization pipeline. Schema metadata is the only thing that crosses the wire to Seedfast's generation service, so review the data path against your security policy the same way you would any new vendor. [Staging without production data](/blog/staging-without-prod-data) walks through the workflow. ## Wrapping up The short version: use `db.prisma.io:5432` for seeds, never `pooled.db.prisma.io:5432`. Configure `DIRECT_URL` in `prisma.config.ts` and reach for `@prisma/adapter-ppg` only when the seed has to run on an edge runtime. Either pick a maintenance schedule for `seed.ts` or hand the job to a tool that reads the schema on every run — what doesn't work is pretending a hand-written seed will keep up with twenty migrations a quarter. If a Friday afternoon spike on the seed file is what brought you here, the direct URL fixes today's break. The deeper fix is to stop hand-rolling the dataset every time the schema moves. Seedfast does that for Prisma Postgres in two minutes — [connect to your database](/docs/seed-your-database) and run a seed before you close the laptop. ## Related guides - [How to seed a database: PostgreSQL practical guide](/blog/seed-database) — the framework-agnostic version of this article, covering raw SQL, Prisma, Drizzle, TypeORM, and `node-postgres` - [Database seeding: methods and best practices](/blog/database-seeding) — the conceptual companion covering reference vs test data, idempotency, and when seed files stop scaling - [Database seeder tools compared](/blog/database-seeder) — quick reference for Prisma, Drizzle, TypeORM, and standalone tools side by side - [Seed file maintenance](/blog/seed-file-maintenance) — why static seed files fall out of sync with the schema, and what to do about it - [How to seed a Neon database](/blog/seed-neon-database) — the Neon sibling to this guide, with the same pooler-vs-direct dance and a deep dive into branching - [How to seed a Supabase database](/blog/supabase-db-seed) — the Supabase sibling, covering `seed.sql`, `supabase db reset`, and Supabase preview branches - [Staging database without production data](/blog/staging-without-prod-data) — the compliance-driven workflow for fintech, healthcare, and anyone who can't pipeline PII into staging - [Get started with Seedfast](/docs/seed-your-database) — connect to your Prisma Postgres database and run your first schema-aware seed --- # Circular Foreign Key Seed: Three Workarounds That Actually Run > Source: https://seedfa.st/blog/circular-foreign-key-seed > Last updated: 2026-08-19 **To seed two Postgres tables that reference each other, mark the foreign keys `DEFERRABLE INITIALLY IMMEDIATE` and wrap the inserts in a transaction that issues `SET CONSTRAINTS ALL DEFERRED` — Postgres then validates both FKs at `COMMIT` instead of after each row.** ```sql -- both foreign keys must already be declared DEFERRABLE (see Workaround 1) BEGIN; SET CONSTRAINTS ALL DEFERRED; INSERT INTO organizations (id, name, primary_owner_id) VALUES (1, 'Acme', 1); INSERT INTO employees (id, full_name, organization_id) VALUES (1, 'Ada', 1); COMMIT; ``` A circular foreign key seed is the case where two Postgres tables reference each other and your seed file has no valid first row to insert. You pick a parent, INSERT it, and Postgres replies with `violates foreign key constraint`. You flip the order. Same error, from the other direction. Every ordering fails the constraint check the moment the row hits disk. This is a narrower problem than the generic foreign-key ordering one. The general FK case has a [topological-sort answer](/blog/test-data-postgresql), which is to insert parents before children, the ordering [database seeding](/blog/database-seeding) walks through. The circular case doesn't have a sort answer; it's a different shape of fix. Postgres supports three patterns for it, and this article works through each in turn, starting with deferred constraints, then a nullable back-edge with a two-phase UPDATE, then a single-statement data-modifying CTE. After those, [Seedfast](https://seedfa.st/) runs against both versions of the schema below, and the two runs are worth reading side by side, because one of them fails. ## Key Takeaways - A circular foreign key seed fails by default because Postgres checks each `FOREIGN KEY` constraint immediately after each row. With two tables that reference each other and both columns `NOT NULL`, no insertion order satisfies both checks at row-write time. - The canonical fix is `DEFERRABLE INITIALLY IMMEDIATE` on the constraint plus `SET CONSTRAINTS ALL DEFERRED` inside the seed transaction. Postgres then validates the constraints at `COMMIT` instead of after each row. - If one side of the cycle is genuinely optional, a nullable back-edge plus a two-phase `INSERT … INSERT … UPDATE` is simpler and doesn't change constraint semantics for normal app traffic. - A single data-modifying CTE that inserts both sides with explicit, cross-referencing keys satisfies both foreign keys at the end of the statement — so it seeds the cycle even on the strict schema (`NOT NULL` on both sides, no deferral) without a nullable column. The cost is that every key has to be known and hand-written up front. - Before reaching for any workaround, ask whether the cycle is intrinsic to the domain or an accident of the schema. Removing a back-edge that loses business meaning is the first case; removing one that only changes how queries are written is the second, and refactoring beats `INITIALLY DEFERRED` there. - Seedfast works from the live schema, so a cycle with a nullable back-edge, or one whose constraints are genuinely deferred at runtime, fills without a line of hand-written seed SQL. A cycle that is `NOT NULL` on both sides with no deferral defeats every tool equally, and the deferred-constraint pattern in Workaround 1 stays the answer there. ## Why a circular foreign key seed fails on the first INSERT Take the simplest concrete case the brief in your head is probably already running on. Two tables. Each one references the other. Both back-edges are `NOT NULL` because the domain says they have to be: ```sql CREATE TABLE organizations ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, primary_owner_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE employees ( id BIGSERIAL PRIMARY KEY, full_name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, organization_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE organizations ADD CONSTRAINT organizations_primary_owner_id_fkey FOREIGN KEY (primary_owner_id) REFERENCES employees(id); ALTER TABLE employees ADD CONSTRAINT employees_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id); ``` Every employee belongs to an organization. Every organization has a primary owner who is one of its employees. Both halves are real, both halves are required, and the schema is fine — until the seed file runs. The naive seed reaches for the parent table first: ```sql BEGIN; INSERT INTO organizations (id, name, primary_owner_id) VALUES (1, 'Northwind Logistics', 1); INSERT INTO employees (id, full_name, email, organization_id) VALUES (1, 'Sam Patel', 'sam.patel@northwind.example', 1); COMMIT; ``` The output: ``` BEGIN psql:/work/naive-insert.sql:9: ERROR: insert or update on table "organizations" violates foreign key constraint "organizations_primary_owner_id_fkey" DETAIL: Key (primary_owner_id)=(1) is not present in table "employees". psql:/work/naive-insert.sql:12: ERROR: current transaction is aborted, commands ignored until end of transaction block ROLLBACK ``` The first of those lines is SQLSTATE [23503](/tools/postgres-error-code-lookup#23503), whose DETAIL names the key that was missing and the table Postgres went looking in. The reason is one sentence: by default, Postgres runs each `FOREIGN KEY` check at the moment a row is inserted, not at `COMMIT`. The organization row needs an existing employee `1`, and that row doesn't exist yet. Flipping the order — employee first — produces the symmetric error from `employees_organization_id_fkey` because the organization the employee is supposed to belong to doesn't exist either. The cycle has no entry point under the default check timing. That timing is what each of the workarounds below changes, in different ways. ## Is your circular FK actually circular, or is it a schema smell? The competitor articles that rank for this query mostly skip this question and jump straight to `INITIALLY DEFERRED`. It's worth pausing on, because the answer changes which workaround is the right one — or whether you need one at all. There's a useful distinction between **intrinsic** cycles and **accidental** ones. An intrinsic cycle is one where removing either back-edge changes the meaning of the data. The `organizations` ↔ `employees` cycle above is intrinsic: an organization without a designated primary owner is a different domain object, and an employee with no organization is too. Both directions of the relationship encode something the business actually cares about, and the cycle is the honest expression of that — it's also the case where [what referential integrity actually guarantees](/blog/referential-integrity) starts to matter, because the cycle is the constraint, not just an ordering puzzle. For these, you have to pick a workaround — the cycle isn't going away. An accidental cycle is one where removing the back-edge only changes how queries are written. A common accidental shape is a `users.created_by_user_id` self-reference plus a `created_users` denormalized column on the parent — the same fact represented twice, in opposite directions, "for query performance". Another is a parent table with a `latest__id` pointer — the child already references the parent, and the back-pointer exists so a join can be skipped. In both cases, the cycle is a cache, not a fact. The fix is to drop the cached column and write the join. A practical heuristic: if you remove the back-edge and the team has to re-explain a business rule (every organization must have a primary owner), the cycle is intrinsic. If you remove it and the team only has to rewrite one query, it's accidental, and the schema is the bug. For accidental cycles, the cheapest workaround is the migration that removes the cycle. A junction table dissolves the back-edge into a join. Moving one column to a third table — for example, an `organization_owners` table with `organization_id` and `employee_id` — preserves the data without forcing either parent table to know about the other. These are migrations, not seed-script tricks, and they pay back every time anyone touches the schema afterwards. For intrinsic cycles, keep reading. In practice, cycles often arrive after the fact: a migration last sprint added a back-edge — a `created_by_user_id` on `users`, a `latest_invoice_id` on `customers` for a dashboard query — and the seed file that was fine yesterday now fails. This is a more common origin story than the schema looking circular from day one. ## Workaround 1: deferred constraints inside the transaction The canonical Postgres answer is to tell the constraint to defer its check to `COMMIT`. Two pieces: 1. The constraints must be declared [`DEFERRABLE`](https://www.postgresql.org/docs/current/sql-createtable.html). The default is `NOT DEFERRABLE` and must be changed at the constraint level, either in the original `ALTER TABLE` or in a follow-up migration. 2. Inside the seed transaction, [`SET CONSTRAINTS ALL DEFERRED`](https://www.postgresql.org/docs/current/sql-set-constraints.html) flips the runtime behavior so checks run at `COMMIT` instead of after each row. If your migrations live in an ORM (Prisma, Drizzle, TypeORM), this redeclaration is a raw-SQL migration step regardless — the `DEFERRABLE` flag isn't expressible in their schema DSLs. ```sql -- Step A: redeclare the constraints as DEFERRABLE INITIALLY IMMEDIATE. -- Default-immediate means normal app traffic still gets row-by-row checks. BEGIN; ALTER TABLE organizations DROP CONSTRAINT organizations_primary_owner_id_fkey; ALTER TABLE organizations ADD CONSTRAINT organizations_primary_owner_id_fkey FOREIGN KEY (primary_owner_id) REFERENCES employees(id) DEFERRABLE INITIALLY IMMEDIATE; ALTER TABLE employees DROP CONSTRAINT employees_organization_id_fkey; ALTER TABLE employees ADD CONSTRAINT employees_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) DEFERRABLE INITIALLY IMMEDIATE; COMMIT; -- Step B: the seed transaction itself. BEGIN; SET CONSTRAINTS ALL DEFERRED; INSERT INTO organizations (id, name, primary_owner_id) VALUES (1, 'Northwind Logistics', 1); INSERT INTO employees (id, full_name, email, organization_id) VALUES (1, 'Sam Patel', 'sam.patel@northwind.example', 1); INSERT INTO organizations (id, name, primary_owner_id) VALUES (2, 'Cascadia Foods', 2); INSERT INTO employees (id, full_name, email, organization_id) VALUES (2, 'Mira Okafor', 'mira.okafor@cascadia.example', 2); COMMIT; ``` Run against the schema above: ``` BEGIN ALTER TABLE ALTER TABLE ALTER TABLE ALTER TABLE COMMIT BEGIN SET CONSTRAINTS INSERT 0 1 INSERT 0 1 INSERT 0 1 INSERT 0 1 COMMIT table_name | count ---------------+------- organizations | 2 employees | 2 (2 rows) ``` Two trade-offs to know. First, deferred constraints cost more memory: Postgres has to track the pending checks until `COMMIT`, and the bigger the transaction the higher the queue. For seed files that's usually fine; for million-row bulk loads it's worth measuring. [Cybertec's PostgreSQL blog](https://www.cybertec-postgresql.com/en/blog/) makes the same point about the memory cost of holding deferred checks. Second, `INITIALLY IMMEDIATE` is the safer default than `INITIALLY DEFERRED`: it leaves normal application transactions on row-by-row checking and only relaxes that for sessions that explicitly opt in with `SET CONSTRAINTS ALL DEFERRED`. The seed file opts in; the live app doesn't. ## Workaround 2: nullable foreign key + two-phase insert If your business rule actually allows one side of the cycle to be temporarily empty — for example, an organization can exist for a few rows of bookkeeping before its primary owner is decided — make that column `NULL`-able and break the cycle in three statements: ```sql ALTER TABLE organizations ALTER COLUMN primary_owner_id DROP NOT NULL; BEGIN; INSERT INTO organizations (id, name, primary_owner_id) VALUES (1, 'Northwind Logistics', NULL), (2, 'Cascadia Foods', NULL); INSERT INTO employees (id, full_name, email, organization_id) VALUES (1, 'Sam Patel', 'sam.patel@northwind.example', 1), (2, 'Mira Okafor', 'mira.okafor@cascadia.example', 2); UPDATE organizations SET primary_owner_id = 1 WHERE id = 1; UPDATE organizations SET primary_owner_id = 2 WHERE id = 2; COMMIT; ``` The trade-off is the obvious one: this requires the column to be nullable. If the domain says every organization must have a primary owner from the moment of its creation, you can't do this — the column has to stay `NOT NULL`, and you're back to Workaround 1. Don't drop `NOT NULL` purely to get the seed file to run, because that quietly weakens a business rule that the rest of the application relies on. Where this pattern earns its keep is when one side of the cycle is genuinely optional in the domain — a `manager_id` that's nullable for the CEO's row, a `parent_category_id` that's nullable for the root category. In those cases the schema already allows it, the seed file doesn't have to fight `DEFERRABLE`, and the result reads like ordinary code. ## Workaround 3: data-modifying CTE for one-shot inserts A `NOT DEFERRABLE` foreign key is checked at the end of each statement, not after each row — and a data-modifying [`WITH … RETURNING`](https://www.postgresql.org/docs/current/queries-with.html#QUERIES-WITH-MODIFYING) is one statement. So you can insert both sides of the cycle, each row naming the other's key, in a single statement: write the employees in a CTE, then the organizations in the outer `INSERT`. By the time the statement ends and the foreign keys are checked, both rows exist, and both checks pass. ```sql WITH new_employees AS ( INSERT INTO employees (id, full_name, email, organization_id) VALUES (1, 'Sam Patel', 'sam.patel@northwind.example', 1), (2, 'Mira Okafor', 'mira.okafor@cascadia.example', 2) RETURNING id ) INSERT INTO organizations (id, name, primary_owner_id) VALUES (1, 'Northwind Logistics', 1), (2, 'Cascadia Foods', 2); ``` This is the one pattern here that runs on the strict schema unchanged — both back-edges `NOT NULL`, neither constraint `DEFERRABLE`, no `SET CONSTRAINTS`. The order of the two INSERTs doesn't matter, and a dangling key still fails the end-of-statement check and rolls the whole statement back, so you don't trade away referential integrity to get the rows in. The catch is that every cross-referencing key has to be known and written by hand into the one statement. That's fine for a bounded set of reference rows; it doesn't scale to a seed file with hundreds of rows, and it can't lean on `BIGSERIAL` — you can't reference an id the sequence hasn't handed out yet. It also doesn't help an automated seeder: a tool that inserts one table per statement never has both sides of the cycle in a single statement, so the end-of-statement trick isn't available to it. It's a sharp tool for a small, one-shot cyclic insert, written by hand. There's also a fourth pattern in the wild — `ALTER TABLE … DISABLE TRIGGER ALL` to bypass FK checks during a sync. A widely-shared rubyrep gist recommends it for replication. Don't use it in a seed file. Disabling triggers lets bad data in and bypasses domain constraints, which is the failure mode the seed file is supposed to catch in the first place. ## What Seedfast does with a cyclic schema The patterns above pull two different levers. Deferred constraints and the two-phase insert change *when* the check runs, either postponed to `COMMIT` or split so the back-reference lands in a later `UPDATE`. The single-statement CTE instead packs both sides into one statement, so the end-of-statement check already sees a closed cycle. The first two are the ones a tool can apply on your behalf, and Seedfast reads the schema before it writes, so a nullable side or a constraint that actually defers is enough for it to fill the cycle with no seed-side SQL from you. The CTE stays hand-written, because it needs every key up front. That word "permits" is doing real work. Here's what happens when Seedfast runs against the strict-NOT-NULL schema we started with — both back-edges `NOT NULL`, neither constraint declared `DEFERRABLE`: ``` [2026-05-01T13:07:57+02:00] INFO: Seeding started [2026-05-01T13:08:06+02:00] INFO: Tables in scope: public.organizations, public.employees [2026-05-01T13:08:06+02:00] INFO: Planned: 8 records across 2 tables [2026-05-01T13:08:06+02:00] INFO: Auto-approving plan (scope provided) [2026-05-01T13:08:07+02:00] INFO: Seeding table public.organizations (1/2) [2026-05-01T13:08:07+02:00] INFO: Seeding table public.employees (2/2) [2026-05-01T13:09:31+02:00] ERROR: Failed table public.organizations: Failed: delta=0 < expected=2 (baseline=0, final=0) [2026-05-01T13:09:31+02:00] ERROR: Failed table public.employees: Failed: delta=0 < expected=6 (baseline=0, final=0) [2026-05-01T13:09:31+02:00] INFO: Seeding completed: 0/2 tables succeeded, 0 rows, 95.67s [2026-05-01T13:09:31+02:00] WARN: 2 tables failed ``` It fails. So does every other seeder, because no tool can violate a `NOT NULL` immediate-checked constraint that Postgres itself enforces row by row. Filling the tables is what Seedfast is for, while enforcing the constraint stays with the database, and that line doesn't move. A `DEFERRABLE INITIALLY IMMEDIATE` variant behaves the same way unless the constraints are actually deferred at runtime. Until they are, it's still a strict cycle, and declaring or deferring them is a schema decision the operator makes. Now drop `NOT NULL` from `organizations.primary_owner_id` — the nullable variant we used in Workaround 2 — and run the same scope: ``` [2026-05-01T13:11:58+02:00] INFO: Seeding started [2026-05-01T13:12:03+02:00] INFO: Tables in scope: public.organizations, public.employees [2026-05-01T13:12:03+02:00] INFO: Planned: 8 records across 2 tables [2026-05-01T13:12:03+02:00] INFO: Auto-approving plan (scope provided) [2026-05-01T13:12:04+02:00] INFO: Seeding table public.organizations (1/2) [2026-05-01T13:12:04+02:00] INFO: Seeding table public.employees (2/2) [2026-05-01T13:12:32+02:00] INFO: Table public.organizations completed: 2 rows in 27.144s [2026-05-01T13:12:32+02:00] INFO: Table public.employees completed: 6 rows in 27.144s [2026-05-01T13:12:32+02:00] INFO: Seeding completed: 2/2 tables succeeded, 8 rows, 35.16s ``` Two organizations and six employees came back with each organization's primary owner drawn from its own staff, and nobody hand-wrote a two-phase INSERT to get there. Give Seedfast a schema with room in it, meaning one side nullable or `DEFERRABLE INITIALLY DEFERRED` declared and accepted at runtime, and it closes the cycle on its own while the operator writes no seed-side SQL at all. The strict case, `NOT NULL` on both sides with no deferral, is a schema decision rather than a tooling one, and no software solves it. If your seed file keeps running into this and your schema permits the two-phase write, [Seedfast](https://seedfa.st/) reads that schema and closes the cycle for you on your own database. If it doesn't permit it, the deferred-constraint pattern in Workaround 1 is still the cleanest answer, and it's worth rolling once into the migration that adds the back-edge so the next person to write a seed file doesn't repeat this. ## Frequently asked questions ### How do you seed two Postgres tables with circular foreign keys? **Declare both foreign keys `DEFERRABLE INITIALLY IMMEDIATE`, then wrap the seed inserts in a transaction that runs `SET CONSTRAINTS ALL DEFERRED`.** Postgres validates both constraints at `COMMIT` instead of after each row, so the two mutually-referencing rows can be inserted in either order inside one transaction without tripping the foreign-key check. ### Why does every insert order fail with a circular foreign key? **By default Postgres checks each `FOREIGN KEY` the moment a row is written, not at `COMMIT`.** When two tables reference each other and both columns are `NOT NULL`, the first row you insert always references a parent that doesn't exist yet — and flipping the order produces the symmetric error from the other constraint. The cycle has no valid entry point under immediate checking. ### What does `SET CONSTRAINTS ALL DEFERRED` do? **It postpones every deferrable constraint check in the current transaction until `COMMIT`.** Only constraints already declared `DEFERRABLE` are affected; non-deferrable ones still fire row by row. The live application keeps immediate checking unless it opts in, so the relaxed timing stays scoped to the seed transaction that issued the statement. ### Can you seed a circular foreign key without making a column nullable? **Yes — two patterns do.** Deferred constraints keep both columns `NOT NULL` and validate at `COMMIT`. A single data-modifying CTE that inserts both sides with explicit cross-referencing keys also keeps them `NOT NULL`, because a non-deferrable foreign key is checked at end-of-statement and a writable CTE is one statement. Only the two-phase `INSERT … INSERT … UPDATE` needs a nullable back-reference column. ### Does `DEFERRABLE INITIALLY IMMEDIATE` slow down normal application traffic? **No — `INITIALLY IMMEDIATE` leaves everyday transactions on row-by-row checking.** Only a session that explicitly runs `SET CONSTRAINTS ALL DEFERRED` relaxes the timing. Deferred checks do cost extra memory, because Postgres queues the pending checks until `COMMIT` — that matters for million-row bulk loads, but rarely for a seed file. ## Related guides - [What is Referential Integrity?](/blog/referential-integrity) — the property the cycle is bumping into, with the self-referential and composite-key edge cases that share the same insert-order discipline - [Postgres Seed Script: Survive the Next Migration](/blog/postgres-seed-script) — what to do once you have working SQL: idempotency, sequence resets, and writing a seed script that doesn't break the next time the schema moves - [Get started with Seedfast](/docs/seed-your-database) — connect to your PostgreSQL database and run a seed against your own schema in under five minutes --- # Enterprise Database Test Data: What It Actually Looks Like > Source: https://seedfa.st/blog/enterprise-database-test-data > Last updated: 2026-06-05 > **TL;DR:** Enterprise database test data is test data managed as a system rather than a script, the point at which an organization's environment count, schema size, and compliance footprint outgrow seed scripts and `pg_dump` snapshots. Teams solve it three ways: subset production, mask production, or generate from scratch. Seedfast generates relational data from the live schema, so regulated teams populate dev, CI, and staging without moving a single production row. This is the practical companion to [the broader test data management category](/blog/test-data-management): if that piece defines the discipline, this one shows what enterprise database test data actually looks like in working Postgres at the scale and sensitivity a regulated mid-size company runs. Not the platonic vendor diagram. The real one — the one your CI runs against on Tuesday morning, the one your platform team is tired of fixing. By "enterprise" we don't mean Fortune 500. We mean the moment your environment count, schema size, and compliance footprint cross a threshold where seed scripts and `pg_dump` snapshots stop being viable, and you have to think about test data as a system rather than a script. ## Key Takeaways - Enterprise database test data isn't just bigger fixture data — it's a different problem with different constraints: scale variance, regulated sensitivity, multi-environment refresh, organizational coordination - The three levers — subset production, mask production, synthesize from scratch — solve overlapping problems with different trade-offs; none is the right answer everywhere - Different environments deserve different data shapes: a unit test wants a dozen rows, perf wants production-shaped distribution, demo wants a story. One-size-fits-all is the silent source of "works in CI, breaks in staging" bugs - Tenant isolation in test data and timestamp/lifecycle realism cause more demo embarrassments and test-flake than any data volume problem - Between fragile DIY scripts and six-figure enterprise TDM procurement sits a third option. Seedfast reads a live Postgres schema and generates the data from it, which fits regulated mid-size teams who can't touch production and can't absorb a multi-quarter rollout either ## Why enterprise database test data is a different problem Three things separate enterprise test data from a single team's fixtures: scale, sensitivity, and environment count. **Scale.** Postgres tables in production grow into the millions of rows. Schemas grow into the hundreds of tables. Foreign-key chains run six or seven hops deep — a `payment_event` references a `payment` references an `order` references a `cart` references a `customer` references an `account` references a `tenant`. Hand-rolled seed scripts hit a wall around 30 tables. Past that, somebody has to think about insert order, dependency cycles, and how many `order_items` should hang off a typical `order` to look real. **Sensitivity.** Production data in a regulated company isn't just sensitive — it's regulated. A copy of prod on a developer laptop can be a finding in your next SOC 2 review, a potential [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/index.html) violation if the schema includes patient records, or a [PCI DSS](https://www.pcisecuritystandards.org/) compliance problem if cardholder fields are involved. The classic move — `pg_dump` prod, restore to staging, `UPDATE users SET email = ...` over the top — is the [staging-without-prod-data trap most teams already know is broken](/blog/staging-without-prod-data) but haven't replaced. **Environment count.** A small team has local + CI + staging. A mid-size regulated company has local-dev, CI, integration, staging, perf, and demo at minimum. Each one wants different data: tiny in CI, representative in staging, production-shaped in perf, narrative in demo. The "one giant snapshot for everything" approach quietly creates more bugs than it solves — tests that pass in CI and fail in staging because the data shape is different. Some of that environment count is architectural rather than procedural, too: a schema split across a dozen service-owned Postgres databases multiplies the same six environments by every service boundary, and [seeding databases that reference each other across services](/blog/microservice-seeding) is a distinct problem from seeding one enterprise schema, however large that schema gets. ## The constraints that shape it Four constraints shape every enterprise team's test-data approach. None of them are optional; the question is how you handle each. **Volume that varies by purpose.** Unit tests want a dozen rows. Integration tests want hundreds. Staging wants something representative — five-figure rows for most tables, six-figure for hot ones. Perf wants production-shaped distribution at production-shaped volume. Most teams pick one volume and force every environment to live with it. Bugs follow. **Referential integrity across many related tables.** Postgres enforces [foreign keys](https://www.postgresql.org/docs/current/tutorial-fk.html); that's the database's job. Whatever fills the tables has to produce rows that satisfy them, including the [circular references](/blog/circular-foreign-key-seed) that show up when a `payment` carries an FK to an `order` and the `order` carries one back to `payment_id` for the latest payment. A 30-table fintech schema has dozens of these. Hand-rolled scripts handle three or four; past that they collapse. **Sensitivity gravity.** Once a schema includes any regulated field, whether that is PHI, cardholder data, account numbers, or EU resident PII, the cost of putting production rows anywhere outside production climbs sharply. HIPAA, PCI DSS, SOC 2, and [GDPR data minimization](https://gdpr-info.eu/art-5-gdpr/) all push toward the same outcome: don't move production rows into dev environments at all. The pull of regulated data is gravitational; everything else in your test-data strategy rotates around it. [Seedfast](https://seedfa.st/) generates the data fresh from the schema on every run, which leaves the regulated rows exactly where they already are. **Refresh cadence.** Staging data refreshed monthly is stale. Refreshed nightly is expensive. Refreshed on-demand requires the refresh to be cheap, fast, and reliable. The cadence question is downstream of the volume question: small data refreshes cheaply and often; production-sized snapshots refresh slowly and rarely. Teams who tie all environments to the same refresh process end up with stale staging *and* expensive perf runs. ## The three levers: subset, mask, synthesize Three working approaches to enterprise database test data. Each one solves part of the problem; none of them solves all of it. **Subset production.** Take a slice of prod — a few thousand customers and their related rows — and copy it down. Subsetting tools handle the FK closure: pull customer 1234 and you also pull their orders, payments, addresses, audit rows. The data is real, so your tests behave like production. The trade-off is that subsetted data is still production data. It carries the same regulatory weight as the full database, just with fewer rows. For a HIPAA shop or a PCI-scope team, subsetting alone isn't enough — you'd still have PHI on a developer laptop. **Mask production.** Take that subset (or the full snapshot) and run masking rules over the sensitive columns. Names get replaced with fake names; emails get hashed; card numbers get tokenized. Done well, masking produces realistic-looking data without the regulated values. Done poorly — and it's often done poorly because masking config drifts behind schema changes — masking produces obviously-broken data: emails that don't validate, addresses that don't match cities, foreign keys that reference rows the subsetting step dropped. Masking also requires production database access for the masking step, which is its own access-control problem. **Synthesize from scratch.** Generate the data without ever touching production. Seedfast works this way against Postgres, reading the schema and producing realistic connected rows that satisfy the constraints it finds. [Schema-aware test data generation](/blog/test-data-generation) of this kind needs no access to production data at all, so there is no subset to define, no masking pipeline to run, and no prod connection to get approved. The trade-off is that synthetic data doesn't reproduce production's exact shape: you won't accidentally hit a real customer's edge case because you've never seen it. Some teams treat that as a feature; others want at least one perf environment that mirrors production-shaped distributions. Each lever has a legitimate niche. Synthesizing is the default for environments that should never see production data at all — local-dev, CI, demo. Masking-after-subset still fits perf testing for teams already running a TDM platform. Subsetting fits a narrow case where production access is allowed and the tool's masking is trusted. Most regulated mid-size teams end up with a mix, and the mix is what makes test data hard, not any single lever. ## Scale variance across environments Here's the section the rest of the test-data discourse mostly skips. Most articles talk about "production-like data" as if every environment wants the same thing. The opposite is true. A working enterprise test-data setup recognizes four distinct scales, each with a different answer. **Unit-test scale (1–50 rows per relevant table).** Unit tests want the smallest data that exercises the code path. Twelve users, three orders each, one payment status. Fast to set up, easy to assert against. Generated synthetically per-test is fine; copying any version of prod here is slow and brings risk that doesn't pay off. **CI/integration scale (hundreds to a few thousand rows).** CI runs against a populated database that resembles a real one but stays cheap to provision. The shape is more important than the volume — every status code represented at least once, every FK chain populated, edge-case dates included. This is where most teams over-provision; CI databases sized like staging slow pipelines down without catching more bugs. **Staging/demo scale (tens of thousands of rows).** Staging needs realistic-looking data because humans interact with it — internal users, sales demos, support reps. A dashboard with 50,000 plausibly-distributed records reads as "real" the same way one with 500,000 would. Demo-specific data often gets even more narrative shaping ("the persona Linda has 14 orders across 6 months, last one delivered yesterday") so screenshots tell the right story. **Perf scale (production-shaped distribution at production-shaped volume).** Perf is the only environment where matching production matters. Query plans, index hit rates, autovacuum behavior, lock contention — all of these depend on data distribution and volume. Synthetic data at perf scale is achievable but requires careful distribution control: the long tail matters, hot rows matter, skew matters. Under-size this environment and you ship [the bugs that only real test data catches](/blog/small-data-big-lies) — missing indexes, N+1 queries, and memory blowups that never appear against a few dozen rows. The argument: one tool that can hit any of these four scales from the same schema specification is more valuable than one that does any single scale well. Most teams own four different test-data pipelines (a fixtures library for unit, a Faker script for CI, a `pg_dump` for staging, a vendor TDM for perf) and pay coordination cost across all four. The win isn't more data; it's the same data engine producing different shapes on demand. ## Tenancy boundaries and audit-trail consistency Two practical issues most TDM articles ignore — and both produce bugs that survive into production. **Tenant isolation in test data.** Multi-tenant SaaS schemas have a `tenant_id` (or `org_id`, or `account_id`) column on most rows, and isolation is enforced with tenant-scoped query filters or [Postgres row-level security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) so tenant 1 only sees tenant 1's data. When test data is generated naively, foreign keys cross tenant boundaries: a `tenant_1` user owns an `order` whose `product_id` belongs to `tenant_2`'s catalog. That order will never appear in production — the application wouldn't allow it — but it shows up in tests, causes assertion failures, and worst case slips into a demo environment where a sales rep notices that one tenant can see another tenant's data. Multi-tenant database seeding has its own constraints; the rule is that every tenant-scoped FK has to stay within tenant. **Audit-trail and lifecycle consistency.** Real records have a story: a user account is `created`, then `email_verified`, then `active`, eventually `suspended` or `closed`. The `created_at`, `updated_at`, `verified_at`, `last_login_at` timestamps line up with that story. Most generated data ignores this; you end up with users whose `verified_at` predates `created_at`, accounts with `last_login_at` after they were closed, status `active` rows whose `closed_at` is set. None of this is wrong by the database's lights — the constraints are satisfied — but it produces bugs nobody catches in tests because the *data* is the lie. Demo embarrassments are the visible version of this; the silent version is integration tests that pass against impossible state. These are problems schema-aware generation has the chance to solve and hand-rolled scripts almost never do. They're also what turns "we have synthetic data" into "we have synthetic data that actually behaves like production data." ## The procurement gap and how teams fill it Most existing content presents two paths for enterprise test data: hand-rolled seed scripts at the small end, six-figure enterprise TDM at the high end. There's a gap in the middle, and a lot of teams live in it. The gap is shaped like this. A regulated mid-size company — fintech, insurtech, healthtech, B2B SaaS in SOC 2 scope — has more than fifty engineers, a Postgres schema in the high two-digit table count, and a hard rule that production data cannot leave production. Hand-rolled seed scripts can't keep up with the schema; they break on every migration, and nobody owns them. But the team can't justify a six-figure procurement cycle either. Procurement takes two quarters. The platform takes two more quarters to roll out. By the time the TDM is online, the schema has moved twice and the data-shape problem is half-solved at six-figure cost. This isn't a "smaller customer for the enterprise tool" problem. It's a different category. The team doesn't need anonymization-of-production; they need data that was never production in the first place. They don't need a six-month rollout; they need something to drop into CI this sprint. The enterprise TDM platforms aren't a discount tier away from the right answer — they're answering a different question (anonymize what we have) than the one the team is asking (generate what we don't). Schema-aware generation fills that gap. Seedfast reads the schema, generates from scratch, and never asks for a production connection. That isn't a discount enterprise TDM, it's a different lever, and it happens to fit the regulated-mid-size shape better than either of the alternatives. The [data seeding tools spectrum](/blog/data-seeding-tools) lays out where each category lands on the price/capability axis if you want a side-by-side, and the [GenRocket alternative](/compare/genrocket-alternative) guide covers the case where a team was quoted for enterprise TDM but only needs to seed a Postgres test database. Seedfast generates relational test data from your Postgres schema, with no production rows involved. You point it at a connection string, describe the scenario in plain English, and review the plan before anything is written: ```bash seedfast seed --scope "fintech app: 5,000 accounts with transactions, payments, and varied balances" → Connected to PostgreSQL ✓ Found 41 tables, 78 foreign keys → Generating data... → Review the plan ? Apply this plan? (y/N) y ✓ Seeded 61,240 rows across 41 tables ``` Only schema metadata — table and column shapes — crosses the wire to Seedfast's generation service so the model knows what to build; row values never do. If schema names are themselves sensitive in your shop, that data path is the thing to review against your security policy, the same way you would any new vendor. For a team that's already decided "no production data leaves production" and isn't ready to run a multi-quarter procurement, that's the third option that's been missing from most of the writing on this topic. ## Where to put what across the SDLC The operational closing question: which approach belongs where? A working enterprise test-data setup matches each environment to the data shape it actually needs. - **Local-dev:** synthesized data, small, regenerated per-developer. No production access ever. Refresh on demand from the schema; if the schema changes, the next run picks it up. - **CI:** synthesized data, small-to-medium, generated per-test-run or cached as a template. The CI database should be cheap to provision and identical across runs. This is where flaky tests pile up if the data shape drifts. - **Integration / preview environments:** synthesized data, medium scale, refreshable on demand. When a developer pushes a branch and gets a preview environment, the data should be there in minutes, not hours. - **Staging:** synthesized data at staging scale, refreshable nightly or on-demand. If sales and support use staging for demos, the data needs narrative shaping — named personas, recognizable scenarios. - **Perf:** the only environment where matching production-shaped distribution matters. For most regulated teams, this is also the only environment where masked-after-subset still earns its keep, *if* the team already has a TDM platform. Otherwise, schema-aware generation at perf scale, with care taken on distribution control. - **Demo:** synthesized data, scenario-shaped. Demo data should tell a story; sales reps and customer success teams should be able to walk through a known flow without surprises. The pattern across all six environments is one input, your schema, producing different output shapes. Teams that get this right stop coupling all six to a single fragile script, and they stop pretending one snapshot fits everything. Seedfast reads that same schema every time and takes the shape from the scope you describe, so [pointing it at your own Postgres](https://seedfa.st/) is the quickest way to see what each environment gets. ## Frequently asked questions ### What is enterprise database test data? Enterprise database test data is test data managed as a system rather than a script. Once an organization's environment count, schema size, and compliance footprint cross a threshold, seed scripts and `pg_dump` snapshots stop being viable, and the team has to treat test data as infrastructure with its own scale, sensitivity, and refresh constraints. ### Can a regulated team just copy production into lower environments? No. Copying production into dev or CI puts regulated rows — PHI, cardholder data, EU resident PII — outside production, which auditors and regulators generally treat as a serious problem: a likely SOC 2 finding, a potential HIPAA violation where PHI is involved, or a PCI DSS scope issue where cardholder data is present. GDPR data minimization points the same way: don't move production rows into dev environments at all. ### Should an enterprise subset, mask, or synthesize test data? Subsetting copies a real production slice but keeps full regulatory weight. Masking transforms sensitive columns but needs production access and drifts behind schema changes. Seedfast covers the third path, since it works from the live Postgres schema rather than from any production row. Most regulated mid-size teams mix all three, synthesizing for dev, CI, and demo while reserving masked-after-subset for perf if a TDM platform already exists. ### How do you keep foreign keys valid across a large schema? Foreign keys at scale are what hand-rolled scripts lose to. They cope with three or four levels of nesting, then start failing on [circular references](/blog/circular-foreign-key-seed) and on giving each parent a plausible number of children. Seedfast works from the whole schema at once and writes rows whose references resolve, however deep the chain runs. ### How does Seedfast fit a regulated mid-size team? Seedfast reads a live Postgres schema and generates realistic relational data from a plain-English scope, with no production rows in the pipeline. For a team that has ruled out moving production data but can't justify a multi-quarter, six-figure TDM procurement, Seedfast is the schema-aware option that drops into CI without a long rollout. Only schema metadata crosses the wire to the generation service, so review that data path against your security policy the way you would any vendor. ## Related guides - [Test data management](/blog/test-data-management) — the category piece this article is the practical companion to - [Test data generation: 7 methods compared](/blog/test-data-generation) — where schema-aware generation sits against fakers, masking, and ORM factories - [Data seeding tools](/blog/data-seeding-tools) — the price/capability spectrum from DIY scripts to enterprise TDM - [Staging without prod data](/blog/staging-without-prod-data) — why the regulated audience can't just copy production into staging - [HIPAA test data](/blog/hipaa-test-data) — the healthcare-specific version of the no-production-data constraint - [Load testing with an empty database](/blog/load-testing-data) — why enterprise-scale row counts, not a handful of fixtures, are what load tests actually need - [Get started with Seedfast](/docs/seed-your-database) — see what schema-aware generation looks like against your own Postgres --- # HIPAA Test Data: What Compliance Really Requires > Source: https://seedfa.st/blog/hipaa-test-data > Last updated: 2026-07-22 **This page covers HIPAA regulation and de-identification standards for test data.** If you need the relational and temporal mechanics of healthcare schemas (FK chains, prescription-to-encounter ordering, lab-value trajectories across visits), see [test data for healthcare apps](/blog/test-data-for-healthcare). Search "hipaa test data" and you get two SERPs collapsed into one: marketing pages about verifying that your software meets HIPAA technical safeguards, and a much smaller pocket of writing about populating dev databases for healthcare apps. This article is about the second one: what the regulation requires, and how to choose a HIPAA test data generator that keeps PHI out of dev entirely. If you're an engineer maintaining a Postgres schema with `patients`, `encounters`, and `prescriptions`, and your compliance lead has banned production copies in non-prod environments, the practical question is what HIPAA compliant test data actually looks like, and what the regulation requires about it. This isn't a HIPAA primer. It's the engineering view: what the regulation says about non-production environments, how that maps to columns in a real Postgres schema, and what changes when the proposed Security Rule update is finalized. (For the relational and temporal side, what shape healthcare test data needs at the row level independent of regulation, see [test data for healthcare apps](/blog/test-data-for-healthcare).) > **TL;DR:** HIPAA compliant test data contains no protected health information (PHI). The Privacy Rule's §164.514, through Safe Harbor or Expert Determination, decides whether your dev or staging database is in HIPAA scope. Seedfast generates realistic rows from your Postgres schema without reading a patient record, which is how a non-production environment stays out of scope in the first place. ## Key Takeaways - HIPAA does not define the term "test data", but the Security Rule (45 CFR §164.308–§164.312) and the Privacy Rule's de-identification standard (§164.514) effectively decide what your dev and staging databases can hold. Any environment with PHI is in HIPAA scope. - The 18 Safe Harbor identifiers map directly onto columns in a typical healthcare schema: `full_name`, `email`, `phone`, `dob`, `address`, `mrn`, IP address, device ID, and free-text fields like `notes` and `chief_complaint`. - Copying production into staging and "anonymizing" the obvious columns leaves PHI in free-text fields, JSON blobs, audit trails, and FK-linked context, and it pulls the entire staging environment under BAA, audit-log, and MFA obligations. - The proposed May 2026 Security Rule update raises the cost of PHI-in-dev sharply: MFA and encryption become required, annual penetration tests and biannual vulnerability scans become explicit, and network segmentation gets a mandate. Whatever environment holds PHI gets all of that. - Masking gets an environment out of scope after the fact. Seedfast never reads a production row at all, so the dev and staging databases it fills were never in scope to begin with. ## What HIPAA actually says about test data HIPAA never uses the phrase "test data". The statute is built around two regulations that matter for engineers: the Security Rule (45 CFR §164.308–§164.312), which sets administrative, physical, and technical safeguards for ePHI, and the Privacy Rule's de-identification standard (§164.514), which describes when patient data is no longer protected. §164.514 defines two paths to de-identification: - **Safe Harbor**: remove 18 specific identifiers and have no actual knowledge that the remaining data could identify a person. The 18 identifiers are listed verbatim in [HHS guidance](https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html#standard): names, dates more granular than year, geographic subdivisions smaller than state (with the 3-digit ZIP rule), telephone, fax, email, SSN, MRN, account numbers, biometric identifiers, full-face photos, and so on. - **Expert Determination**: a qualified statistician documents that the risk of re-identification is "very small". If your dev database contains data that satisfies one of these, it's not PHI. If it doesn't, it is, even if you call it "test data" in a sprint planning doc. Naming has no force here; the data shape decides. This sits on top of a structural rule: any environment that stores, processes, or transmits PHI is in HIPAA scope. Scope brings business associate agreements (BAAs) for every cloud service touching that environment, audit logging on access, breach notification obligations, encryption at rest and in transit, and access controls aligned with §164.308. None of that goes away because an environment is "just staging". The practical consequence: deciding what your test data looks like is, on a HIPAA-bound system, a regulatory decision about scope. Either your dev database holds something that meets §164.514 (and the environment is out of scope), or it doesn't (and the environment is in). ## What counts as PHI in your Postgres database Most engineers know HIPAA covers names and SSNs. The 18 identifiers list is wider, and several of them ride quietly inside columns nobody flags during code review. Here is what the Safe Harbor identifiers look like mapped to a typical healthcare Postgres schema, with a working `CREATE TABLE` of the kind of schema this article keeps coming back to: ```sql CREATE TABLE patients ( id uuid PRIMARY KEY, mrn text UNIQUE NOT NULL, -- PHI: medical record number full_name text NOT NULL, -- PHI: name email text NOT NULL, -- PHI: email phone text, -- PHI: phone dob date NOT NULL, -- PHI: date more granular than year address text, -- PHI: street address zip5 text, -- PHI: ZIP unless reduced to 3 digits ip_last_seen inet, -- PHI: device IP created_at timestamptz NOT NULL ); CREATE TABLE providers ( id uuid PRIMARY KEY, full_name text NOT NULL, npi text ); CREATE TABLE encounters ( id uuid PRIMARY KEY, patient_id uuid REFERENCES patients(id), provider_id uuid REFERENCES providers(id), occurred_at timestamptz NOT NULL, -- date more granular than year — PHI chief_complaint text, -- free-text — usually PHI diagnosis_code text -- code alone is not PHI; in context it can be ); CREATE TABLE prescriptions ( id uuid PRIMARY KEY, encounter_id uuid REFERENCES encounters(id), patient_id uuid REFERENCES patients(id), drug_code text NOT NULL, written_at timestamptz NOT NULL, notes text -- free-text — usually PHI ); ``` Things that surprise people on first audit: - **`dob` is PHI.** Any date more granular than year, attached to an individual, counts. A `date_of_birth` column with full dates fails Safe Harbor. - **`ip_last_seen` is PHI.** IP addresses are explicitly in the 18 identifiers. - **Free-text fields are usually PHI.** `chief_complaint`, `notes`, `provider_comments`, and any `description` column tend to leak names, dates, and addresses dictated by clinicians or pasted from prior records. A clean schema with dirty free-text is still a PHI-holding environment. - **Audit and event tables.** `audit_log.actor_email`, `events.payload` (JSON), and `feature_flags.user_id` are easy to forget when building a "no PHI in dev" inventory. - **The FK chain itself can re-identify.** A 6-row `patients` table that ties to a 30-row `encounters` table that ties to a 12-row `prescriptions` table can identify someone even if no individual column does, when the FK pattern is rare enough. Generating synthetic data from the schema addresses this by construction. The FK chain is what makes [referential integrity across patient records](/blog/referential-integrity) genuinely matter for HIPAA, not just for tests passing. A useful exercise before you decide what your dev database should hold: open your schema and tag every column as PHI, not-PHI, or depends-on-content. The columns that surprise you are usually where the leak is, and the list grows with every migration. [Seedfast](https://seedfa.st/) fills each of those columns with a value that never came from a real person, so nobody has to keep the tagging exercise current as the schema moves. ## Why production copies in Postgres dev environments are the default mistake The most common pattern for getting "realistic" data into a dev database is a sanitized production dump. It looks responsible: take a `pg_dump`, run an `anonymize.sql` script that overwrites obvious PHI columns, restore. So, people feel they did something rigorous. The pattern fails in four ways that compound: **1. Anonymization scripts only cover columns somebody remembered.** Six months in, three new tables exist, two columns were renamed, and a feature added a JSON `metadata` column that now contains free-text from clinicians. The script doesn't update itself, it doesn't reach into JSON, it also doesn't anonymize free-text fields like `chief_complaint` (you can't blindly rewrite clinical narrative without breaking tests) - so the script is a partial fix that the team treats as complete. **2. Free-text and FK-linked context survive masking.** Suppose `patient.full_name` is overwritten with `'Test User ' || id`. The `notes` column on the corresponding `prescription` row still says "Patient and her husband John mentioned the move to 412 Elm Street." - as the example shows, such masking still exposes sensitive patient data, which turns real free-text masking into a large NLP project, not just a SQL UPDATE. Unfortunately, most teams still ship without it. **3. The non-prod environment is now in HIPAA scope, fully.** PHI in staging means staging needs a BAA chain, audit logging, MFA, encryption at rest, encryption in transit, and breach-notification readiness. Even when the team says "it's only there for an afternoon", and especially when one developer restored a snapshot to staging "for a few hours" to debug a billing bug (which is the moment the compliance lead finds out about it), the environment was in scope while the data sat there - even a short 3-hour exposure is a 3-hour HIPAA event. **4. The auditor sees the dump.** A SOC 2 auditor reviewing test-data sources will definitely ask about them, and the honest answer "we copy production and run a script that the team wrote in 2023" earns a finding for a reason. So, the team that says "our dev environments do not contain PHI" needs to demonstrate the path that data takes, and "we generate data just from the schema" is the cleanest version of that demonstration (day-to-day, this is what [staging environments built without production data](/blog/staging-without-prod-data) try to make feasible). The deeper issue is that production-copy-then-mask treats PHI as something you reduce to manageable levels in non-prod, but the regulation doesn't operate that way. Either an environment has PHI (in scope, with the full obligations) or it doesn't (out of scope). There is no middle tier called "lightly anonymized" - the audit happens at the binary. ## HIPAA Security Rule testing requirements for dev environments (2026 update) A status note before specifics: as of writing, OCR has not issued the final rule on the new HIPAA Security Rule. OCR announced the proposed rule (NPRM) on [December 27, 2024](https://www.hhs.gov/hipaa/for-professionals/security/hipaa-security-rule-nprm/factsheet/index.html) and published it in the [Federal Register](https://www.federalregister.gov/documents/2025/01/06/2024-30983/hipaa-security-rule-to-strengthen-the-cybersecurity-of-electronic-protected-health-information) on January 6, 2025, with the comment period closing March 7, 2025. May 2026 is the target finalization date on the regulatory agenda, but the timing is not guaranteed, and many teams are planning against the proposed text rather than waiting for the final version. What the NPRM proposes, with the dev-environment translation in plain terms: - **MFA becomes required, not "addressable"**. Every login to an environment storing or processing ePHI needs MFA. If staging holds PHI, every developer's path into staging (bastion, VPN, IAM console, database admin tool) gets an MFA gate. If staging holds only generated data, this requirement applies to production but not to that environment. - **Encryption at rest and in transit become required.** Same scope question: if PHI never enters the dev disk, the rule applies upstream of dev rather than to it. - **Annual penetration tests and biannual vulnerability scans.** Both must cover any system handling ePHI. A staging environment with anonymized-prod data is in that category. A staging environment seeded from the schema isn't. - **Network segmentation mandate.** Production networks holding PHI need to be segmented from non-PHI environments. This is much easier to draw on a network diagram when dev and staging are decisively in the non-PHI category. - **Stricter audit cadence and incident-response obligations.** Audit logs covering ePHI access become mandatory at higher granularity. Staging access counts. - **A 180-day compliance window after the final rule is published**, with phased deadlines for specific safeguards. The earliest obligations would land on production systems within six months of publication; subsequent safeguards roll in on the schedule the final rule sets. There's a conclusion engineering teams are reaching when reading the NPRM that compliance teams sometimes reach later: the cheapest way to prepare for the rule is to remove PHI from environments where it doesn't need to be. Production stays in scope (it has to). Dev and staging don't have to, and the new rule makes "have to" much more expensive when they are. ## Three approaches teams use to populate non-production databases The decision about what to put in your dev database almost always reduces to one of three approaches. They are not three flavors of the same thing; they map to different scope outcomes under HIPAA. | Approach | What it does | HIPAA scope effect | Honest tradeoffs | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Production copy (with or without masking)** | `pg_dump` from prod, restore to dev/staging, optionally run a masking script. | Dev/staging is **in scope**. Even with masking, free-text and JSON columns and audit trails carry PHI; the environment has obligations. | High realism. Fast to set up the first time. Ongoing scope cost: BAAs, MFA, audit logs, encryption, breach readiness for a non-prod environment. | | **Masked or de-identified subset** | Pull a slice of prod, push through an anonymization tool that rewrites flagged columns. Enterprise tools (Tonic Structural, Delphix, K2View) sit here. | Can move dev/staging **out of scope** if the de-identification meets §164.514, but only when the masking is exhaustive and reviewed by an Expert Determination process. Setup is weeks; these are typically six-figure annual contracts. | Solves the masking gap if done thoroughly. Still requires a connection to production and a security review for that connection. The pipeline itself is in HIPAA scope. | | **Schema-aware generation (no production access)** | Read the live schema, generate realistic relational data from scratch, write it to dev/staging. | Dev/staging **can stay** out of scope because no PHI enters from the generator. The generation tool reads schema metadata, not patient rows. | No production access at all. Predictable cost. Realism depends on the generator's domain understanding — generated data is shaped right but is not your actual users' data, which matters if a bug only repros against real production patterns. PostgreSQL-only for now if you're using Seedfast (other generators support broader databases with their own tradeoffs). | The phrase "no production access" carries more weight than it sounds like. It cuts the BAA conversations for the masking vendor, the security review for that connection, the row-count negotiations between data engineering and compliance, and the audit question of who at the masking vendor has access to PHI. [Seedfast](https://seedfa.st/) works that way by construction. It reads your schema, generates the patients → encounters → prescriptions chain with every reference resolving, and never asks for a production credential. Day 02 in this series covered the broader category, [test data management](/blog/test-data-management), without anchoring on regulation. Here the comparison is HIPAA-specific, and the column that matters is "scope effect", not "how realistic does the data look in screenshots". ## What HIPAA test data looks like in practice If you choose the third approach, the next question is what the data should actually look like. "No PHI" is a binary; "realistic enough that integration tests find real bugs" is a target. Both have to be true. A generated row in `patients` for a healthcare app might look like: ``` id: a4c1... (random UUID, not from prod) mrn: MRN-1857293 (synthetic format, no collision with real MRN range) full_name: Priya Adesanya (plausible name, not a real patient) email: priya.adesanya@example.test (reserved test domain) phone: +1-555-0148 (555-01XX is reserved for fictional use) dob: 1979-04-22 (plausible age, not a real DOB) address: 14 Maple Court, Springfield (fictional) zip5: 45203 (real ZIP shape; not tied to a real person) ip_last_seen: 198.51.100.42 (TEST-NET-2 reserved range) ``` Several things are intentionally true about this row: - It's individually realistic: first name distribution, surname plausibility, email format, age cohort, phone format. Tests that touch these fields exercise the same code paths they would in production. - None of it traces back to a real person. The 555-01XX phone range, the `example.test` domain, and the TEST-NET reserved IP range exist precisely so engineers can use them without colliding with real numbers. - The MRN format is internal-system-shaped without being real. If your production MRNs follow a pattern, generated MRNs follow the same pattern but in a non-production range. - It's reproducible if the generator is seeded, and disposable if it isn't. Either is a choice. A generated row in `encounters` for the same patient might look like: ``` id: e8a2... patient_id: a4c1... (FK to the patient above) provider_id: 91c5... (FK to a generated provider) occurred_at: 2026-02-14 09:30:00+00 (within plausible visit window) chief_complaint: "Routine follow-up for blood pressure check; patient reports good adherence." diagnosis_code: I10 (illustrative code; verify against your code set) ``` The `chief_complaint` is a generated narrative: clinical-shaped, no real person attached. The `occurred_at` falls inside a window that's coherent with the patient's `created_at`. The `provider_id` resolves to a generated provider row, not a NULL or a dangling integer. A generated row in `prescriptions` continues the chain: ``` id: rx40... encounter_id: e8a2... (FK to the encounter above) patient_id: a4c1... (FK to the patient above) drug_code: DRUG-PLACEHOLDER-A12 (illustrative; map to your code set) written_at: 2026-02-14 09:35:00+00 (after encounter.occurred_at) notes: "Continue current regimen; recheck in 3 months." ``` Three properties this row has that a Faker output would not: - **Seedfast keeps every reference valid**: `encounter_id`, `patient_id`, and the chain back to `provider_id` are all valid references to rows that exist. Faker scripts get this wrong roughly every time someone adds a table. - **Temporal coherence.** `prescriptions.written_at` is later than `encounters.occurred_at`, which is later than `patients.created_at`. None of this is enforced by `CHECK` constraints in most schemas; a generator that respects narrative time avoids the kind of test bug where a prescription appears to have been written before its encounter happened. - **Domain-shaped values.** The clinician-shaped narrative in `notes`, the format of `mrn`, and the consistency of phone with country are what turn a generated row from "passes constraint checks" into "passes a sniff test from a domain expert". None of this is about beating Faker on a feature checklist. Seedfast produces rows with these properties from your schema alone, which lets a dev database do real work without pulling the environment into scope. ## How schema-aware generation removes PHI from the development surface The SERP still files synthetic data under anonymization, but they part on the input: anonymization starts with PHI and reduces it, while schema-aware generation starts with none. The [compliant test data](/blog/compliant-test-data) guide works through that masking-versus-generation split in full; what matters here is where each one lands a HIPAA-bound environment. Seedfast needs the schema definition, meaning the table list, the column types, and the constraint set, and not a single row of patient data. What it writes back is a set of valid, connected rows across every table, patients and encounters and the rest of the reference chain, with domain-shaped values in the free-text fields rather than random strings. The whole operation reads no PHI because there is no PHI to read. One path detail belongs in your vendor review: that schema definition (table and column names, types, and constraints) is sent to an AI provider (OpenAI) to generate the data. Row values never leave your database, but because table and column names can themselves be sensitive in healthcare, put that data path in front of your compliance lead the way you would any sub-processor. For a HIPAA-bound team, the operational result is that dev and staging stop being environments-with-masked-PHI and become environments-with-no-PHI, so obligations keyed to PHI presence apply to production rather than following data into environments that never received it. ## Choosing a HIPAA test data generator The best HIPAA test data generator for an application database is one that produces realistic, relational data from your schema so no PHI is ever in the loop, with valid foreign keys and a CLI you can run in CI. That rules out tools that mask production (PHI is still in the pipeline) and tools that emit a standard research format you then have to ETL into your schema. The deciding column below is HIPAA scope effect, not screenshot realism. | Tool | Generates from schema (no PHI read) | Relational FK integrity | Your app's arbitrary Postgres schema | CLI / CI-native | HIPAA scope effect | | -------------------------- | ----------------------------------- | ----------------------- | ------------------------------------ | ----------------------- | -------------------------------- | | Seedfast | ✓ | ✓ | ✓ | ✓ | Keeps dev/staging out of scope | | Tonic Fabricate | ✓ | ✓ | ✓ (chat or Live Connect) | ✗ (no live-DB seed CLI) | Keeps dev/staging out of scope | | Mockaroo | ✓ | ✗ | partial (export/import) | ✗ | Out of scope, but not relational | | Synthea | ✓ | ✓ (FHIR model) | ✗ (FHIR/CSV/OMOP, not your schema) | partial | Out of scope, research format | | Tonic Structural / Delphix | ✗ (masks prod) | ✓ | ✓ | partial | Pipeline processes real PHI | A few honest "best for" notes, because none of these is bad at what it was built for: - **Tonic Fabricate** generates from scratch like Seedfast and handles relational data well. The difference is workflow: it is a web/chat agent that can connect to a live database or work from a described schema, but has no one-command live-DB seed CLI. Its free plan now splits by the email used to sign up, giving $5 a month in usage credits and a restricted model set for a personal address, or $10 a month in credits and the full model lineup for a work address. The Plus plan is still $29 a month, with per-message metering running roughly $0.17 for a standard turn and $0.37 for a complex one, as of July 2026. Seedfast reads the live Postgres schema from a connection string and runs as one CLI/CI command with no per-token metering. The [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate) comparison covers the workflow split in depth. - **Mockaroo** is fast for flat tables (its free tier gives 1,000 rows per request), but it generates column by column with no cross-table foreign-key integrity, so a multi-table healthcare schema is exactly where it struggles. Honest concession: for a single denormalized export, it is quicker than installing anything. - **Synthea** is genuinely excellent at what it does, clinically realistic synthetic patient life histories, and is the right tool for research and population-health datasets. But it outputs FHIR, CSV, and OMOP, not your application's arbitrary Postgres schema, so using it to seed your dev database means writing and maintaining an ETL pipeline (covered in the sibling [test data for healthcare apps](/blog/test-data-for-healthcare) guide). - **Enterprise anonymization** (Tonic Structural, Delphix) masks production data thoroughly and preserves real distributions, the right pick when your testing depends on exact production patterns. The tradeoff for a HIPAA buyer is that it connects to production and the pipeline still processes real PHI, typically under six-figure annual contracts. For the wider Postgres field beyond the HIPAA lens, see the [best postgres test data generator](/blog/best-postgres-test-data-generator) comparison and the regulated-team [data seeding tools](/blog/data-seeding-tools) pillar. The broader cross-regulation version of this argument lives in [compliant test data](/blog/compliant-test-data). ## Seed a healthcare schema with no PHI in the loop A HIPAA-bound team can fill dev or staging straight from its live Postgres schema, with no production connection and no PHI in the loop. Point Seedfast at the schema, describe the clinical scenario, and let it generate the coherent `patients → encounters → prescriptions` chain: ```bash seedfast seed --scope "clinic with 300 patients, a year of encounters, and chronic-care prescription patterns" ``` ``` → Connected to PostgreSQL → Found 22 tables, 38 foreign keys → Generating data... → Done. Seeded 18,400 rows in 7.2s ``` Seedfast reads schema metadata (table and column names, types, foreign keys) and not patient rows, so there is no PHI to read and nothing to mask. Keep the sub-processor caveat above in your compliance review: the schema definition is sent to an AI provider to generate the data, and in healthcare a table or column name can itself be sensitive. Seedfast does not make you HIPAA compliant on its own (no tool does); it keeps PHI out of the environment so the environment can be out of scope. Try it on the [free plan](/pricing), or watch the [one-command demo](/#demo). ## FAQ ### Can we use a small subset of real PHI in dev if we de-identify it ourselves? Yes, if the de-identification meets §164.514, either Safe Harbor (all 18 identifiers removed and no reasonable basis to believe the remainder identifies a person) or Expert Determination. In practice, most ad-hoc de-identification scripts miss free-text fields, JSON columns, and FK-pattern re-identification risk, and don't meet the standard. Many compliance leads treat any subset originating from production as de facto in scope until proven otherwise. ### Is synthetic test data automatically HIPAA-compliant? HIPAA doesn't certify data sets, and no tool is HIPAA-compliant on its own; only the system as a whole can be. Generated data that contains no information about real individuals is, by definition, not PHI under §164.514. That makes the environment that holds it eligible to be out of HIPAA scope, which is the practical effect teams care about. It does not replace the rest of your HIPAA program for the systems that do hold PHI. ### Does the proposed Security Rule update apply to development environments? The Security Rule applies to any environment that creates, receives, maintains, or transmits ePHI. If your dev environment holds PHI, the proposed MFA, encryption, audit-log, and pen-test requirements would apply to it. If it doesn't, they wouldn't apply to it for that reason. The rule is keyed to PHI presence, not to environment label. ### Do we need a BAA with our test-data tool? You need a BAA with anyone handling PHI on your behalf. A schema-aware generator that reads schema metadata (and not patient rows) is generally not handling PHI; an anonymization tool that connects to production typically is. Your compliance lead is the right reviewer, but the question to put in front of them is "does the tool ever see patient data", and for schema-aware generation the answer is no. One caveat to note in that review: the schema definition itself (table and column names, types, constraints) is sent to an AI provider to generate the data, so confirm that path fits your sub-processor policy. ### Where does this leave SOC 2? SOC 2 auditors will ask about test-data sources during a Type II audit. "Our dev/staging environments do not contain customer or patient data; we generate test data from the schema" is a clean answer. "Our dev/staging environments contain anonymized production data" is an answer that opens further questions about the anonymization process, which means more documentation and more controls. The cross-framework version of this argument — why a [compliant test data tool](/blog/compliant-test-data) keeps data out of GDPR and SOC 2 scope by generating rather than masking — applies the same logic beyond HIPAA. ### What is the best HIPAA test data generator? For seeding an application's database, the best HIPAA test data generator is one that produces realistic relational data from your schema so no PHI ever enters the loop, resolves foreign keys correctly, and runs in CI. That points away from masking tools (which connect to production and process real PHI) and away from research generators like Synthea (which emit FHIR or CSV you'd have to ETL into your schema). Seedfast fits that description on a custom Postgres schema, reading it live from a connection string and running as one CLI or CI command. Tonic Fabricate, Mockaroo, and Synthea also generate without reading PHI, and they differ on relational integrity, schema fit, and workflow. The comparison table above lays out which fits where. ### Is a HIPAA test data generator the same as data masking? No, and the difference decides your HIPAA scope. A generator builds rows from scratch against your schema, so no PHI is ever read and there is nothing to re-identify. Data masking starts from real production records and de-identifies the sensitive fields, which means it needs a production connection and the pipeline still processes PHI. A generator keeps dev and staging out of scope by construction; masking reduces exposure but keeps the pipeline (and often the environment) in scope. ## Related guides - [Test data for healthcare apps](/blog/test-data-for-healthcare): the relational/temporal side of the same problem, what your healthcare schema actually demands, regulation aside. - [Referential integrity across patient records](/blog/referential-integrity): why the FK chain is doing more work in healthcare schemas than in most domains, and what generators have to get right. - [See how Seedfast handles a healthcare schema](/docs/seed-your-database): point it at your schema, describe the clinical scenario you need, and seed dev or staging without production access. --- # Writing a Postgres Seed Script That Survives the Next Migration > Source: https://seedfa.st/blog/postgres-seed-script > Last updated: 2026-09-10 > **TL;DR:** A seed script is a SQL or code file (`seed.sql`, `seed.ts`) that inserts a known set of rows into a Postgres database after migrations run. A good one orders inserts by foreign key, stays idempotent with `ON CONFLICT DO NOTHING`, runs in a transaction, and resets sequences at the end. You're staring at an empty Postgres database - maybe it's a new project, maybe it's a contributor's first day, maybe a migration just nuked the dev DB and the existing seed file no longer applies cleanly. Either way, you're about to write or rewrite a postgres seed script, the file the README tells everyone to run before they can log in locally. This article is about that file - what a good one looks like, the failure modes that quietly turn a good one into a bad one, and the point at which the script stops being the right tool. Running a seed file is the easy part (`psql -f seed.sql` and you're done — covered in [running the script with psql, Prisma, or Drizzle](/blog/seed-database)), what follows is about the script as a maintained artifact. Generating the rows from the live schema on every run skips the seed-file maintenance step entirely, but the long-form answer below is worth a read because the failure modes you avoid show up in every Postgres project sooner or later. ## Key Takeaways - A working seed script depends on three things: foreign-key-ordered inserts, idempotent re-runs (`ON CONFLICT DO NOTHING`), and a sequence reset at the end. Skip any of them and the file breaks the second time someone runs it. - The script that works on the day you write it is not the script that works after the next migration. A `NOT NULL` column added without a default is the canonical failure mode and shows up in real terminal output below. - Hand-rolled seed files scale to a handful of stable reference rows. Past that, they accumulate maintenance debt at roughly the rate your team ships migrations. - Past the pivot point where fixing the file again stops being worth it, a generator that reads the live schema on each run writes the rows itself, so the migration that used to break the seed changes nothing about the command. ## What is a seed script? A postgres seed script is a file (`seed.sql`, `seed.ts`, `db/seeds.rb`, whatever your stack calls it) that inserts a known set of rows into an empty or partially populated database. In a Node.js project the same file is usually called `seed.ts` or `prisma/seed.ts`; in Rails it's `db/seeds.rb`. The mechanics are identical - two distinct jobs hide under the same name: - **Reference data** — country codes, role names, default tenants, feature flags, kept small and production-shipped, versioned right alongside the schema it describes; this kind of data belongs in a seed step or in a migration. - **Development and test data** — the 50 users, 200 orders, and 1,000 line items you need so the dashboard isn't empty on `localhost`. Bigger and throwaway, it still has to look plausible, satisfy every constraint, and come back the same way every time you reset the database. For the conceptual ground around methods and tradeoffs across ORMs, [database seeding](/blog/database-seeding) covers that picture. This article is narrower: a real worked example, the script that fits the schema, and the moments where it falls apart. ## A worked example schema Five tables, real foreign-key relationships. A `users` table, a `teams` table that owns users, a `team_members` join table, `projects` per team, `tasks` per project, `comments` on tasks. The dependency graph reads top-down: users → teams → team_members → projects → tasks → comments. ```sql -- schema.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, full_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE teams ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, owner_id BIGINT NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE team_members ( team_id BIGINT NOT NULL REFERENCES teams(id), user_id BIGINT NOT NULL REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('admin','member','viewer')), PRIMARY KEY (team_id, user_id) ); CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, team_id BIGINT NOT NULL REFERENCES teams(id), name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE tasks ( id BIGSERIAL PRIMARY KEY, project_id BIGINT NOT NULL REFERENCES projects(id), assignee_id BIGINT REFERENCES users(id), title TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('todo','in_progress','done')) DEFAULT 'todo', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id), author_id BIGINT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` Two things to notice. The `tasks.assignee_id` column is nullable — it's a real-world detail (an unassigned task is valid) and it's the kind of nuance a seed script has to get right. The `team_members` table has a composite primary key, which means its idempotency strategy is slightly different from the others. We'll come back to both. ## Writing the seed file A reasonable hand-rolled seed for the schema above looks like this. Every block has a job: explicit IDs (so test code can assert against them), foreign-key-ordered inserts, `ON CONFLICT DO NOTHING` for re-run safety, a transaction wrapper, and a sequence reset at the end so the next inserted row doesn't collide with id `3`. Where a statement arrives from a query log on one line, the [PostgreSQL formatter](/tools/postgresql-formatter) puts it back into the shape below before you paste it in. ### An example seed.sql file ```sql -- seed.sql BEGIN; INSERT INTO users (id, email, full_name) VALUES (1, 'alice@example.com', 'Alice Anderson'), (2, 'bob@example.com', 'Bob Brown'), (3, 'carol@example.com', 'Carol Chen') ON CONFLICT (id) DO NOTHING; INSERT INTO teams (id, name, owner_id) VALUES (1, 'Platform', 1), (2, 'Frontend', 2) ON CONFLICT (id) DO NOTHING; INSERT INTO team_members (team_id, user_id, role) VALUES (1, 1, 'admin'), (1, 2, 'member'), (2, 2, 'admin'), (2, 3, 'member') ON CONFLICT DO NOTHING; INSERT INTO projects (id, team_id, name) VALUES (1, 1, 'Auth refactor'), (2, 1, 'Billing cleanup'), (3, 2, 'Design system v2') ON CONFLICT (id) DO NOTHING; INSERT INTO tasks (id, project_id, assignee_id, title, status) VALUES (1, 1, 1, 'Move login to OAuth flow', 'in_progress'), (2, 1, 2, 'Audit JWT expiry handling', 'todo'), (3, 2, 1, 'Reconcile payment webhooks', 'done'), (4, 3, 3, 'Token migration plan', 'todo') ON CONFLICT (id) DO NOTHING; INSERT INTO comments (id, task_id, author_id, body) VALUES (1, 1, 2, 'Started a branch yesterday — almost there.'), (2, 1, 1, 'Nice. Let me know when it is ready for review.'), (3, 3, 1, 'Webhook handler now retries on 5xx.') ON CONFLICT (id) DO NOTHING; SELECT setval('users_id_seq', (SELECT MAX(id) FROM users)); SELECT setval('teams_id_seq', (SELECT MAX(id) FROM teams)); SELECT setval('projects_id_seq', (SELECT MAX(id) FROM projects)); SELECT setval('tasks_id_seq', (SELECT MAX(id) FROM tasks)); SELECT setval('comments_id_seq', (SELECT MAX(id) FROM comments)); COMMIT; ``` Run against a fresh `postgres:16-alpine` container, the captured output is small and boring — which is what you want from a seed script: ```text BEGIN INSERT 0 3 INSERT 0 2 INSERT 0 4 INSERT 0 3 INSERT 0 4 INSERT 0 3 COMMIT users | teams | team_members | projects | tasks | comments -------+-------+--------------+----------+-------+---------- 3 | 2 | 4 | 3 | 4 | 3 (1 row) ``` Three users, two teams, four `team_members`, three projects, four tasks, three comments. Re-running the file produces the same row counts because every block is idempotent. If you'd rather generate richer data than three named users, the standard column-level option is the Node.js + `@faker-js/faker` loop — open a `pg` connection, call `faker.person.fullName()` per row, insert. Faker handles column values; you handle the relationships and the insert order yourself. That's a good fit for flat tables and the place most teams reach for at first. ## Where postgres seed scripts go wrong Most of the bad days a seed file causes come from one of five mistakes. Worth naming each one out loud, because reviewers tend to skim past them and they all have a fix. All five assume the file parses in the first place; when it does not, the [PostgreSQL syntax checker](/tools/postgres-syntax-checker) names the token the grammar stopped on and the line it sits on. ### Foreign-key ordering Every `INSERT` for a child row needs the parent row to already exist. Inserting `tasks` before `projects` fails. Inserting `comments` before `tasks` fails. The dependency graph in the schema dictates the order in the file, and once you have more than five or six tables it stops being obvious. The harder edge cases are not linear chains. [Circular foreign key cases](/blog/circular-foreign-key-seed) — where two tables reference each other, or a table references itself — need either deferrable constraints ([`SET CONSTRAINTS ALL DEFERRED`](https://www.postgresql.org/docs/current/sql-set-constraints.html)) or a two-pass insert. ### Idempotency A seed file that works once and fails on the second run is broken. Two patterns make a script safe to re-run: [`INSERT ... ON CONFLICT DO NOTHING`](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT) against a unique constraint, and wrapping the whole script in `BEGIN; ... COMMIT;` so a partial failure rolls back instead of leaving the database in a half-seeded state. For tables with a composite primary key (`team_members` above), use `ON CONFLICT DO NOTHING` without specifying columns and Postgres infers the constraint. ### Deterministic vs randomized data Two paths, with a real tradeoff. Deterministic seeds (`(1, 'alice@example.com')`) are reproducible — your tests can assert that user 1 is Alice — but they look fake and they hide bugs that only show up at scale. Randomized seeds (Faker, UUIDs) look real and stress more code paths, but the rows change every run and you can't write tests that pin to specific IDs. Most projects end up with both: a small block of deterministic reference rows for tests and a randomized block for volume. That's the same split [pytest database fixtures](/blog/pytest-database-fixtures) leans on when it seeds a baseline once and lets per-test factories cover the exception. ### Optional vs required columns The seed file above only sets `assignee_id` on tasks where it makes sense, because the column is nullable. If you blindly populate every column, you erase the difference between "this task has no owner yet" and "this task is owned by user 1." The schema documents which columns can be `NULL`; the seed file should respect that distinction or you lose a real piece of test coverage. ### Sequence collisions Inserting an explicit `id = 1` into a `BIGSERIAL` column doesn't advance the sequence. The next `INSERT` without an explicit id tries `1` again and trips the primary key. The [`setval(...)`](https://www.postgresql.org/docs/current/functions-sequence.html) block at the end of the script above is the fix, walking the table to find `MAX(id)` and pointing the sequence at it. Skipping this step is the most common reason a seed file works under `psql -f seed.sql` and then breaks the moment the application tries to insert anything new. Identity columns hit the same wall. The hardcoded `users_id_seq` also stops resolving once a table is renamed, and on an empty table the `MAX(id)` form does nothing at all. Both traps, and the form that avoids them, are in [Postgres sequence out of sync](/blog/postgres-sequence-out-of-sync). A side note on packaging: if you ship a Postgres image to other developers via Docker, dropping `seed.sql` and the schema file into `/docker-entrypoint-initdb.d/` makes the [official `postgres` image](https://hub.docker.com/_/postgres) run them automatically the first time the data directory is empty. That gets you a one-command local environment without a separate seed step in your dev script. The compose-file side of that setup, healthchecks and the first-boot-only volume trap included, has its own walkthrough in [docker compose seed database](/blog/docker-compose-seed-database). ## What happens after the next migration Seed files tend to break later, not when you first write them — the moment a migration adds a required column the seed doesn't know to populate. It's the failure mode no tutorial wants to show. The seed file works, so you merge it. Three weeks later, somebody writes a migration that adds a `visibility` column to `projects` — required, no default — because the product needs public and private projects. ```sql -- migration-after.sql ALTER TABLE projects ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; ALTER TABLE projects ALTER COLUMN visibility DROP DEFAULT; ``` Adding a `NOT NULL` column with a default and then dropping the default is a common migration pattern: existing rows get the default, future inserts have to supply the value explicitly. The migration is fine. The seed file is now wrong, and it doesn't know it. Re-run the same seed against the post-migration schema and you get this — captured verbatim from a `postgres:16-alpine` container: ```text ALTER TABLE ALTER TABLE --- now re-run hand-rolled-seed.sql, expect FAILURE on projects insert --- BEGIN INSERT 0 0 INSERT 0 0 INSERT 0 0 psql:/work/hand-rolled-seed.sql:29: ERROR: null value in column "visibility" of relation "projects" violates not-null constraint DETAIL: Failing row contains (1, 1, Auth refactor, 2026-05-01 04:29:21.587522+00, null). ``` Three tables seeded zero rows because the whole script is wrapped in a transaction and the `projects` insert rolled the lot back. Nothing visible in the database. The error blames `visibility`, though the deeper problem is structural — the seed file was written against schema version _N_ and the database is now at version _N+1_. Every migration that adds a required column, a check constraint, or a foreign key creates one more place where the same kind of failure can happen. ## When the script is no longer the right tool A hand-rolled seed file is fine when the schema is small and stable. It pays for itself when most of your sprint goes into application code and migrations are rare. The math changes when you've fixed the same file three times in two months — you've started spending more time keeping the seed in step with the schema than writing the features the seed is supposed to demonstrate. If your file is already in this state, [seed file maintenance](/blog/seed-file-maintenance) covers the cost-side of the same problem in more depth. The structural fix is to stop describing data by hand and start generating it from the schema as it exists right now. Seedfast reads the live database every time it runs — including the new `visibility` column — and fills every table with valid, connected rows automatically. Against the post-migration schema above, with no edits to seed code, the captured run looks like this: ```text [2026-05-01T06:30:34+02:00] INFO: Seeding started [2026-05-01T06:30:46+02:00] INFO: Tables in scope: public.users, public.teams, public.team_members, public.projects, public.tasks, public.comments [2026-05-01T06:30:46+02:00] INFO: Planned: 115 records across 6 tables [2026-05-01T06:30:46+02:00] INFO: Auto-approving plan (scope provided) [2026-05-01T06:30:48+02:00] INFO: Seeding table public.users (1/6) [2026-05-01T06:30:59+02:00] INFO: Table public.users completed: 8 rows in 11.249s [2026-05-01T06:30:59+02:00] INFO: Seeding table public.teams (2/6) [2026-05-01T06:31:07+02:00] INFO: Table public.teams completed: 3 rows in 8.017s [2026-05-01T06:31:08+02:00] INFO: Seeding table public.team_members (3/6) [2026-05-01T06:31:08+02:00] INFO: Seeding table public.projects (4/6) [2026-05-01T06:31:19+02:00] INFO: Table public.team_members completed: 8 rows in 11.532s [2026-05-01T06:31:19+02:00] INFO: Table public.projects completed: 6 rows in 11.685s [2026-05-01T06:31:19+02:00] INFO: Seeding table public.tasks (5/6) [2026-05-01T06:31:33+02:00] INFO: Table public.tasks completed: 30 rows in 13.736s [2026-05-01T06:31:33+02:00] INFO: Seeding table public.comments (6/6) [2026-05-01T06:31:44+02:00] INFO: Table public.comments completed: 60 rows in 10.852s [2026-05-01T06:31:44+02:00] INFO: Seeding completed: 6/6 tables succeeded, 115 rows, 72.45s ``` Six of six tables filled, 115 rows, connected and valid on insert. The hand-rolled file had to be edited to know about `visibility`. Seedfast did not, because it found the new column in the schema and supplied a value for it. Reading the live schema and generating data that fits it, with no seed-code changes when the schema moves, is what this run actually demonstrates. The bigger arguments about data realism live in articles where the evidence supports them. If you want to see the same workflow end-to-end on your own schema, [seedfa.st](https://seedfa.st/) walks through the first run. Running it as a pipeline step rather than a local command is covered in the [CI/CD database seeding guide](/docs/cicd-database-seeding), down to the API key and the scope syntax. ## Frequently asked questions ### What is a Postgres seed script? A Postgres seed script is the file that loads a starter set of rows into Postgres right after migrations run, whether that file is named `seed.sql`, `seed.ts`, or `db/seeds.rb`. The split that matters is what kind of data it's carrying, because small production-shipped reference rows and the throwaway bulk a dashboard needs to look alive on `localhost` are different problems, and that split decides whether the rows belong in a migration or a generator. ### What's the difference between a seed script and seed.sql? `seed.sql` is one specific file name for a seed script; "seed script" is the umbrella term that also covers `seed.ts` (often `prisma/seed.ts` in Node.js) and `db/seeds.rb` in Rails. The mechanics stay identical across all of them: insert a known set of rows into an empty or partially populated database. Most teams treat the SQL form as the cross-stack baseline because `psql -f seed.sql` runs without a language runtime. ### How do I make a seed script idempotent in PostgreSQL? Use `INSERT ... ON CONFLICT DO NOTHING` against a unique constraint (or `ON CONFLICT DO NOTHING` without specifying columns for composite primary keys), wrap the entire script in `BEGIN; ... COMMIT;` so partial failures roll back, and call `SELECT setval('
_id_seq', (SELECT MAX(id) FROM
))` for every table that uses an explicit `id` value. Miss that last step and everything looks fine until the app itself inserts a row and collides with an id the seed already claimed. ### Why does my seed.sql break after a migration? A migration that adds a `NOT NULL` column without a default value, tightens a check constraint, or introduces a new required foreign key invalidates a seed script written against the previous schema, which has no way of knowing the column exists, so its `INSERT` lacks the value and Postgres rejects the row. The worked example earlier in this article shows this exact failure mode against a `visibility` column added post-merge. Seedfast avoids that whole class of break by reading the schema when it connects, which turns a new required column into just another column to fill. ## Related guides - [Seed file maintenance](/blog/seed-file-maintenance) — the maintenance-burden side of the same problem, in detail. - [Database seeding methods and best practices](/blog/database-seeding) — the broader picture across SQL files, ORMs, and generators. - [Database seeder tools and per-ORM quick reference](/blog/database-seeder) — when you're choosing the seeder tool itself, with command syntax for every major ORM. - [Generate test data in PostgreSQL](/blog/test-data-postgresql) — once the schema gets bigger, the practitioner alternatives to a hand-rolled seed.sql (generate_series, Faker, pg_dump). - [Best Postgres test data generator](/blog/best-postgres-test-data-generator) — when the script stops paying off, the buyer's comparison of schema-aware generators that read the live schema and generate valid data for you. - [Test data generation: methods compared](/blog/test-data-generation) — the cross-stack decision matrix when a seed script stops being the right tool. - [Get started with Seedfast](/docs/seed-your-database) — install the CLI and seed your first database. --- # What is Referential Integrity? Definition and Examples > Source: https://seedfa.st/blog/referential-integrity > Last updated: 2026-09-10 ## What is referential integrity? **Referential integrity is a relational-database property that ensures every foreign key value in a child row points to a primary key value that actually exists in the parent table.** It is enforced by `FOREIGN KEY` constraints, and it is the rule that keeps the relationships between tables from drifting into orphan rows over time. Put plainly, no child row may reference a parent that doesn't exist — a `orders.user_id` value of `42` is only valid while `users.id = 42` is still in the database. **Defined more formally, for every foreign key constraint the set of values in the child column must be a subset of the values in the referenced parent column, at every moment the database is queryable.** The textbook version uses "tuple" instead of "row" and defines it over relations rather than tables, but for a developer working in Postgres or MySQL, the working definition is the one above. ## Summary - A foreign key that doesn't match a live row in the parent table is the one thing referential integrity rules out — nothing more elaborate than that - `FOREIGN KEY` constraints are how the database enforces the property, but every writer of data — app code, migrations, seed scripts alike — is on the hook for satisfying it before a row ever reaches the table - The five constraint actions (`CASCADE`, `NO ACTION`, `RESTRICT`, `SET NULL`, `SET DEFAULT`) decide what the database does when a parent row is updated or deleted while child rows still reference it - When referential integrity breaks, the symptoms are quiet: orphaned rows, joins that drop records, dashboards that under-count - For test data, referential integrity is mostly a producer-side problem: insert order, optional vs. required relations, and cyclic foreign keys are where developers actually hit it - [Seedfast](https://seedfa.st/) generates rows whose foreign keys already resolve to real parents, so a seed run lands without the `violates foreign key constraint` error the database would otherwise raise A small example. You have a `users` table and an `orders` table: ```sql CREATE TABLE users ( id bigserial PRIMARY KEY, email text NOT NULL UNIQUE ); CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), placed_at timestamptz NOT NULL DEFAULT now() ); ``` The `REFERENCES users(id)` clause is what turns `user_id` into a foreign key. From that line forward, the database holds you to one rule: every value in `orders.user_id` must match the `id` of some row in `users`. That rule, applied across every row at every moment, is referential integrity. Referential integrity isn't the same thing as data integrity in general, which is the broader umbrella covering domain constraints (`age >= 0`), uniqueness (`email UNIQUE`), and entity integrity (no row without a primary key). Referential integrity is specifically about the relationship between a child row and its parent. People conflate the two, but they're separate properties — a row can satisfy every domain constraint and still violate referential integrity by referencing a parent that was deleted last Tuesday. One more distinction is worth making early. The database sits on the enforcing side of this property, and everything that writes rows sits on the producer side, obliged to honor what the database is about to check. That producer might be an application, an ORM migration, a fixture loader, or [Seedfast](https://seedfa.st/), which generates its rows against the constraints your schema declares so the database accepts them as they land. The split runs through the rest of the article, and it's the source of most confusion when "referential integrity" shows up in an error message. ## How databases enforce referential integrity The database checks three operations against every foreign key: 1. **`INSERT` into the child** — the database rejects an insert if the foreign-key value doesn't exist in the parent. 2. **`UPDATE` on the parent or child** — if you change a child's foreign-key value, it must still resolve to a parent row. If you change a parent's primary key (rare, but legal in Postgres), the change has to propagate to the children, or the update is rejected. 3. **`DELETE` on the parent** — you can't delete a parent row that still has children pointing at it, unless you've configured the constraint to do something else. That last clause matters. The "do something else" is configured through the constraint's referential actions, declared on the foreign key: ```sql CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE RESTRICT ON UPDATE CASCADE, placed_at timestamptz NOT NULL DEFAULT now() ); ``` Postgres, MySQL/InnoDB, and SQL Server all support the same five actions, with minor syntax variations: - **`NO ACTION`** (the default) — the database raises an error and rolls back the transaction. Postgres defers the check until the end of the statement, which lets you do multi-row updates that are momentarily inconsistent and resolve them before commit. - **`RESTRICT`** — same outcome as `NO ACTION` in most cases, but the check fires immediately rather than at end-of-statement. Useful when you want failures to surface early. - **`CASCADE`** — the database applies the same change to the children. `ON DELETE CASCADE` deletes the orders when their user is deleted; `ON UPDATE CASCADE` rewrites the foreign-key value when the parent's key changes. - **`SET NULL`** / **`SET DEFAULT`** — the foreign key on the child rows is set to `NULL` or to the column's default, leaving the children orphaned-but-valid. Requires the column to be nullable, or to have a default that points to a real parent row. Picking the right action is design work, not configuration. `CASCADE` fits when the child has no independent meaning — a `pageviews` row makes no sense without its `session`. `RESTRICT` fits when the child carries history you can't lose — an order belongs to a user for accounting purposes long after the user record is closed. `SET NULL` fits when the parent is metadata that may go away — an `assigned_to` user on a ticket. The default `NO ACTION` is the right starting point when you haven't decided yet. ## Referential integrity in PostgreSQL: FOREIGN KEY, ON DELETE, DEFERRABLE **Yes, PostgreSQL enforces referential integrity by default through [`FOREIGN KEY` constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK), and the check fires at the end of each statement, not at the end of the transaction.** That detail is the source of most surprises when teams move bulk inserts or seed scripts onto Postgres: a multi-row `INSERT ... SELECT` can momentarily reference rows that the same statement is about to create, and the constraint still resolves cleanly — but a sequence of separate INSERTs cannot. A complete `FOREIGN KEY` declaration with cascading updates and deletes: ```sql CREATE TABLE users ( id bigserial PRIMARY KEY, email text NOT NULL UNIQUE ); CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, placed_at timestamptz NOT NULL DEFAULT now() ); ``` ### All five `ON DELETE` referential actions in Postgres **Postgres supports five `ON DELETE` referential actions: `NO ACTION` (the default), `RESTRICT`, `CASCADE`, `SET NULL`, and `SET DEFAULT`.** They control what happens when a `DELETE` would orphan a child row. - **`NO ACTION`** — the default. Raises a foreign-key violation if any child still references the parent; the check is deferred to end-of-statement so multi-row updates can resolve themselves. - **`RESTRICT`** — same outcome as `NO ACTION`, but the check fires immediately and cannot be deferred. - **`CASCADE`** — deletes the matching child rows along with the parent; the relationship continues to satisfy referential integrity automatically. - **`SET NULL`** — sets the child's foreign-key column to `NULL`, leaving the child row in place without a parent. Requires the column to be nullable. - **`SET DEFAULT`** — sets the child's foreign-key column to its declared `DEFAULT`. Requires that default to itself reference a real parent row, or you've just moved the violation one step downstream. `ON UPDATE` accepts the same five actions and applies the same logic when a parent's primary key changes. ### `DEFERRABLE INITIALLY DEFERRED` for transactions where order can't be controlled **`DEFERRABLE INITIALLY DEFERRED` makes Postgres hold every foreign-key check on the constraint until `COMMIT`, so children and parents can be inserted in any order inside a single transaction.** That's the escape hatch for seed scripts, bulk imports, and any transaction where parent and child are written together but the order isn't fixed — which describes most test-data generation. ```sql CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, placed_at timestamptz NOT NULL DEFAULT now() ); ``` With `INITIALLY DEFERRED`, Postgres holds the foreign-key check until `COMMIT`. You can insert children before parents inside the transaction; as long as every reference resolves by commit time, the database accepts the lot. If the constraint is declared `DEFERRABLE INITIALLY IMMEDIATE` instead, you can opt into deferral per transaction: ```sql BEGIN; SET CONSTRAINTS ALL DEFERRED; -- insert orders and users in any order COMMIT; ``` The constraint is still in force; you've only changed when it's evaluated. Constraints declared without `DEFERRABLE` cannot be deferred at all — `SET CONSTRAINTS` will reject them. The practical consequence: bulk seeds in Postgres either need a topological sort of the foreign-key graph (parents first, children after) or `DEFERRABLE` constraints. There is no third option. This shows up the first time a fixture loader grows past a handful of tables — for the working-through, see [database seeding](/blog/database-seeding) and [the circular-foreign-key seed case](/blog/circular-foreign-key-seed). ## Referential integrity vs entity integrity vs domain integrity The three "integrity" properties travel together in textbooks and get conflated in practice. They are separate, and a row can satisfy any two while violating the third. | Integrity type | What it constrains | Example violation | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | **Referential integrity** | Every foreign key in a child row points to a row that actually exists in the parent table. | `orders.user_id = 42` while no row in `users` has `id = 42`. | | **Entity integrity** | Every row has a non-null, unique primary key, so the row can be referenced unambiguously. | Two rows in `users` with `id = 7`, or a row with `id = NULL`. | | **Domain integrity** | Every column value falls inside the column's declared domain — type, `CHECK`, `NOT NULL`, `UNIQUE` on non-key columns, and so on. | `users.age = -3` under a `CHECK (age >= 0)`, or `users.email = NULL` under `email NOT NULL`. | Referential integrity is specifically the one that depends on the existence of another row. Entity and domain integrity are properties of a single row in isolation. ## What goes wrong when referential integrity breaks Data doesn't dramatically "become corrupt" so much as develop quiet symptoms: - **Orphaned rows.** A child row points to a parent that no longer exists, still sitting in the table, still queryable, still backed up, just with nowhere to go on the next `JOIN` — reports filter it out without saying so, and dashboards quietly under-count. - **Broken joins.** `INNER JOIN` silently drops rows whose foreign key resolves to nothing. The query runs fine; the result is just incomplete. The teams that catch this are usually the ones who notice their numbers don't add up across two reports. - **Restores hit the same wall.** A backup of a child table won't load into a constrained schema unless its parent rows are loaded first — referential integrity constrains your import paths just as much as live writes. - **Migrations that fail at the worst moment.** A migration that adds `NOT NULL REFERENCES` to an existing column will fail on any row whose value doesn't already point to a real parent. Most teams find this out in staging by accident, two days before a release — unless they [review the migration against realistic data first](/blog/migration-review). In Postgres the symptom you'll see during seeding or testing is usually: ``` ERROR: insert or update on table "orders" violates foreign key constraint "orders_user_id_fkey" DETAIL: Key (user_id)=(42) is not present in table "users". ``` What this error actually means is that the database is doing its job, while the producer of the data — the seed script, the migration, the fixture loader — is what put a child in the table before its parent. ## Referential integrity in test data Most developers don't first encounter the phrase "referential integrity" in production. They encounter it in test environments, during seeding or fixture loading, in the form of `violates foreign key constraint` errors. And that's where the framing usually goes wrong. The instinct is to treat the error as a referential-integrity bug — to ask whether the constraint is "right". It usually is. But the database is enforcing referential integrity correctly, and the actual bug sits on the producer side, in the part of the stack writing rows in the wrong order or with values that don't yet have parents. Referential integrity isn't something you turn on or off in a seeder — it's a standing obligation the producer has to meet before rows land. The same is true for [database seeding](/blog/database-seeding) generally — and the [database seeder tools that resolve FK order automatically](/blog/database-seeder) are the ones that escape this whole class of error — and for the broader practice of [test data management](/blog/test-data-management). That distinction has practical consequences: **Insert order is the actual hard problem.** If a schema has 30 tables and 60 foreign keys, a seed script that inserts in alphabetical or migration order will hit a constraint error on the first child whose parent hasn't been written yet. The fix is a topological sort over the foreign-key dependency graph: parents first, children after. For a small schema you can write this by hand. For anything past a tutorial app, it grows fragile fast — every schema change re-orders the graph. This is the failure mode behind most foreign key constraint errors during seeding. **Optional vs. required relations matter.** A nullable foreign-key column means "this row may or may not have a parent". A `posts` table with `author_id bigint NULL REFERENCES users(id)` allows posts with no author — useful for system-generated posts, awkward for queries that assume a join. The choice of nullable vs. non-nullable is design work; once it's made, the producer has to populate the column accordingly. **Cyclic foreign keys exist.** If `companies.primary_contact_id REFERENCES employees(id)` and `employees.company_id REFERENCES companies(id)`, there's no insert order that satisfies both constraints at once — Postgres handles it through `DEFERRABLE` constraints that defer the check to end of transaction, but only if the producer knows to declare them. We cover this case directly under [circular foreign keys during seeding](/blog/circular-foreign-key-seed). All of that is producer-side work, and it is the work [Seedfast](https://seedfa.st/) takes over. Seedfast reads your live schema and hands the database a dataset it will accept as the rows land, which is why the `violates foreign key constraint` error never interrupts a seed. That isn't the same statement as "Seedfast preserves referential integrity", because preserving it is the database's job. Seedfast's side of the arrangement is producing rows that satisfy what your schema already declares. If you've already moved on from hand-rolled fixtures to [generated test data](/blog/test-data-generation), this is the producer-side discipline the practice rests on. For teams in regulated industries, the producer-side framing is a compliance question as much as a correctness one — generating from the schema rather than copying production helps keep PII out of dev environments, and the practice extends outward to [a staging database that never receives a production dump](/blog/staging-without-prod-data). The same insert-order discipline shows up in [test data for HIPAA workflows](/blog/hipaa-test-data) and GDPR-aligned test data. ## How to get test data that stays referentially intact Test data is referentially intact when every foreign key in the generated rows resolves to a parent row that already exists at the moment the row lands, whether that row came from a seed script, a fixture loader, or Seedfast. That makes it a producer-side property rather than a test-only variant of referential integrity, the same rule the database already enforces, now met on the way in by whatever code writes the rows. Hand-written producers get there by writing rows in an order the database accepts and resolving any cyclic reference before the transaction commits, and the mechanics of both are what the next section covers. None of that is code you write when Seedfast produces the dataset, since it generates from the schema in front of it and the rows arrive already satisfying the constraints that schema declares. ## How seeders preserve referential integrity **Seeders maintain referential integrity through three techniques: topologically sorting the foreign-key graph so parents insert before children, declaring cyclic foreign keys `DEFERRABLE INITIALLY DEFERRED` so checks run at `COMMIT`, and generating primary keys (typically UUIDs) before any `INSERT` so foreign-key values are known on both sides of the relationship.** A working [seeder](/blog/database-seeder) doesn't ask the database to be lenient. It hands the database rows in an order, and with values, that already satisfy every foreign key. The three techniques in detail: - **Topological sort of the foreign-key graph.** Treat tables as nodes and foreign keys as edges pointing from child to parent, then sort the graph so every parent is inserted before its children. At the scale where this stops being optional — dozens of tables, twice as many foreign keys — it's the difference between a seed that runs cleanly and one that fails on the first child, and the sort has to be re-run whenever the schema changes, since adding one `REFERENCES` clause can re-order a quarter of the graph. - **`DEFERRABLE` constraints for cycles.** Some foreign-key graphs aren't acyclic — `companies` references `employees(id)` for a primary contact, and `employees` references `companies(id)` for an employer. No topological order exists. The fix is `DEFERRABLE INITIALLY DEFERRED` so both inserts can land in the same transaction and the constraint check waits until commit. - **Generating primary keys upfront.** If you generate UUIDs in the seeder before issuing any `INSERT`, you know the foreign-key value before either row is written. The parent insert uses the same UUID the child insert will reference, and the dependency on database-assigned `bigserial` IDs disappears. This is what makes cross-table consistency in [database seeding](/blog/database-seeding) tractable at scale. A [UUID v7 generator](/tools/uuid-v7-generator) hands you a batch of those keys for a fixture, with the creation time still readable out of each one. Seedfast produces that outcome from your live schema directly, so the dataset lands without a foreign-key error and none of the three techniques above becomes something you script by hand. ## Edge cases worth knowing Foreign keys are only one of several schema features a data producer has to honor. `CHECK` constraints, generated columns, and partial unique indexes can all make a row type-correct yet still invalid — [this breakdown of six Postgres schema features generators skip](https://dev.to/mikh-sh/your-test-data-is-type-correct-and-still-invalid-6-postgres-schema-features-generators-skip-4c7m) walks through the ones that bite most often. A few corners the constraint-action table doesn't cover: - **Many-to-many bridge tables.** A `posts_tags` row holds two foreign keys, one to `posts` and one to `tags`. Both must resolve. The producer has to insert both parents before any bridge row, and the bridge row is the canonical place where insert-order bugs surface in tests. - **Self-referential foreign keys.** A self-referential foreign key is one where the parent and the child are the same table — for example, `employees.manager_id REFERENCES employees(id)` for an org chart, or `comments.parent_id REFERENCES comments(id)` for threaded discussion. Producers have to insert the root rows first (the ones whose foreign key is `NULL`) and order their descendants by depth. The alternative is a `DEFERRABLE INITIALLY DEFERRED` constraint, so the entire tree can be inserted in any order inside one transaction. - **Circular foreign keys between two tables.** When `companies.primary_contact_id REFERENCES employees(id)` and `employees.company_id REFERENCES companies(id)`, neither table can be inserted first without violating referential integrity. There is no topological order. Both constraints have to be declared `DEFERRABLE` and the inserts wrapped in a transaction that runs `SET CONSTRAINTS ALL DEFERRED`. The full walkthrough lives in [circular foreign keys during seeding](/blog/circular-foreign-key-seed). - **Composite foreign keys.** A composite (multi-column) foreign key references a composite primary key — `FOREIGN KEY (tenant_id, user_id) REFERENCES users(tenant_id, id)`. Referential integrity holds across the tuple, not on either column alone. A child row is valid only when the whole tuple matches a row in the parent. Producers that generate columns independently break this without noticing. - **`MATCH FULL` vs `MATCH SIMPLE`.** For composite foreign keys with nullable columns, `MATCH SIMPLE` (the default in Postgres) lets the row skip the check whenever any column in the foreign key is `NULL`. `MATCH FULL` requires either all columns to be non-null and resolving to a parent, or all columns to be `NULL`. `MATCH PARTIAL` is in the SQL standard but not implemented in Postgres. Switch to `MATCH FULL` when "partially-null" foreign keys would be a data bug. - **Soft deletes vs. `CASCADE`.** If you mark rows deleted by setting a `deleted_at` timestamp instead of issuing `DELETE`, `ON DELETE CASCADE` never fires. Children remain pointing at "deleted" parents. That's a separate design choice worth being explicit about; it isn't a referential-integrity failure, but it interacts with how you reason about orphans. - **Deferrable constraints.** Postgres lets you mark a foreign-key constraint `DEFERRABLE INITIALLY IMMEDIATE` (or `DEFERRED`) so the check is postponed to commit. Useful for cyclic graphs and bulk loads. The constraint is still in force; you've just changed when it's evaluated. - **Audit and compliance contexts.** Industries with audit constraints — PCI DSS test data for cardholder data, SOC 2 test data for service-org controls — push the producer-side reasoning further: the producer has to honor referential integrity and avoid leaking real records into the schema at the same time. Across all of these, the database keeps doing the simple thing, while the producer of the data is the one who needs the extra nuance to feed it correctly. ## Frequently asked questions ### What is referential integrity in a database? **Referential integrity in a database is the guarantee that every foreign key in a child table resolves to an existing primary key in the parent table.** It's the property that keeps relational data from accumulating orphan rows — children pointing at parents that no longer exist. Relational databases enforce it through `FOREIGN KEY` constraints; producers of data (applications, migrations, seed scripts) have to honor it on the way in. ### What does referential integrity mean? **Referential integrity means that for every row that holds a reference to another row, that other row actually exists.** Same idea expressed two ways: no orphan foreign keys, and no dangling references between tables. The database checks the property on every `INSERT`, `UPDATE`, and `DELETE` that touches a constrained column. ### What is the difference between a foreign key and referential integrity? **A foreign key is the mechanism; referential integrity is the property it enforces.** A `FOREIGN KEY` is a SQL clause declaring "this column references that one". Referential integrity is the rule that follows from it, requiring every value in the referencing column to match a value in the referenced column. Applications can enforce the property without explicit foreign keys, and foreign keys can be temporarily relaxed with `DEFERRABLE`, but in normal use the two travel together. ### Does PostgreSQL enforce referential integrity by default? **Yes. PostgreSQL enforces referential integrity by default through `FOREIGN KEY` constraints, and the check fires at the end of each statement, not at the end of the transaction.** Bulk inserts whose foreign-key targets are written in the same transaction need either topological ordering or a `DEFERRABLE INITIALLY DEFERRED` constraint. The enforcement is on by default; only the timing is configurable. ### How do you maintain referential integrity in seed data? **You maintain referential integrity in seed data by inserting parent rows before children (via a topological sort of the foreign-key graph), deferring the constraint check to commit time for cycles, or generating primary keys upfront so foreign-key values are known on both sides before either INSERT runs.** Seedfast takes the question off the producer's side entirely, since it generates against the live schema and its output satisfies the constraints declared there. The [database seeder tools](/blog/database-seeder) comparison covers which of the other options do the same; hand-rolled fixture scripts do not, and re-encode the ordering on every schema change. ## Related guides - [Test Data Management](/blog/test-data-management) — the broader practice of producing data that satisfies a schema without copying production - [Generate Test Data with AI](/blog/generate-test-data-with-ai) — why AI coding agents break referential integrity on deep schemas, and the delegation pattern that fixes it - [Test Data Generation](/blog/test-data-generation) — schema-aware generation as the producer-side discipline this article describes - [CI/CD database seeding](/docs/cicd-database-seeding) — generating inside the pipeline, against whatever schema the migration job just built - [Get started with Seedfast](/docs/seed-your-database) — point Seedfast at your database and get valid, referentially intact data out, end to end --- # Test Data Management: Pillars, Tools, and a Working Framework > Source: https://seedfa.st/blog/test-data-management > Last updated: 2026-07-24 Test data management (TDM) is the umbrella term for how a team provisions, maintains, and governs the data its tests run against. The term was shaped almost entirely by enterprise vendors selling to Fortune-500 IT departments, which is why the top SERP results read like one brochure rewritten in different voices. What follows is the guide for teams that never route production data into development: the three pillars, the four tool tiers, and an in-house framework that wraps both. ## Key Takeaways - The category was defined by enterprise vendors selling production-data masking and subsetting at scale, so its vocabulary assumes a buyer that routes prod data into pre-prod, an assumption plenty of teams never make. - Of the three canonical pillars (masking, subsetting, synthetic generation), two turn out to be heritage, because a team that keeps production rows away from development finds most of masking and subsetting unnecessary. - Whether production data is in scope at all is the decision everything else hangs on, and both the tools comparison and the framework below turn on that single answer. - Because Seedfast rebuilds the data from the schema on demand instead of patching a checked-in file, a migration stops being the event that breaks the seed and becomes one the next run absorbs. ## What test data management actually means Test data management is the process of provisioning, maintaining, and governing the data that development, QA, staging, and pre-production environments run against. Beyond creating that data, it covers refreshing it as the schema moves, deciding who may see it, where it lives, and when it gets destroyed. The same term covers a stray `INSERT` in a migration and the policy governing whether a developer ever sees a customer's real name. Two narrower terms often stand in for the whole thing. **Database seeding** is the mechanism of inserting initial data into a database, one moving part of a TDM setup and not a plan for running it. **Data anonymization**, the other frequent stand-in, strips identifying information out of real records. Both sit a layer below TDM's real question, which is how a team treats its test data as a system worth designing. The framing matters because a team treating test data as a script inherits that script's maintenance burden, while one treating it as a managed resource decides deliberately where the data comes from, how it survives a migration, and who it trusts to read it. The planning question is the same whether a shop buys an enterprise platform, writes careful [database seeding](/blog/database-seeding) routines, or reaches for a [seeder tool that keeps foreign keys valid without hand-wiring](/blog/database-seeder). ## How TDM became enterprise-shaped The term came out of enterprise software vendors — Informatica, IBM Optim, the predecessors of Delphix and K2view — who spent the 2000s and 2010s selling to large banks, insurers, telecoms, and government agencies. On the buying side sat a director of QA or a head of data governance reporting to a CIO, and the deployment ran into the millions. The category still carries the shape those buyers pressed into it. Three load-bearing assumptions hold that frame up. The team is presumed to have substantial production data it must push in subset form into pre-prod, masking those sensitive fields is treated as the central challenge, and the procurement happens under a governance program with a compliance officer present. That all held for the original buyer, though none of it has to for a team that has decided, by regulation or policy or deliberate choice, that production data never leaves production. The vocabulary it left behind reinforces those assumptions. Say "test data provisioning" and you have implied pulling from production. "Subsetting" only makes sense against a giant production database, and "masking rules" assume sensitive fields you already collected. That language surfaces on every TDM search, so a team in the production-out-of-scope posture keeps reading guidance built for the opposite one. [Seedfast](https://seedfa.st/) begins at the schema, well upstream of production, and the mismatch keeps that lineage invisible until you look for it. ### The test data management market in 2026 Consolidation has done most of the recent reshaping at the top of the market — Delphix now sells as part of Perforce, and Informatica has moved its legacy on-premise TDM lines to end-of-life in favor of its cloud platform, leaving enterprise buyers to choose among fewer, bigger vendors than five years ago. Below that tier the movement runs the other way. Open-source entrants appear and churn quickly — Neosync's 2025 archival showed how fast that shelf turns over — while the schema-aware generator tier keeps filling out as teams hunt for something between a hand-fed seed script and a six-figure platform. The newest pressure on the category comes from AI-assisted development itself, because pipelines where coding agents build and verify software need data provisioned programmatically and on demand, at a cadence the enterprise refresh cycle was never designed around. ## The three pillars (and why two of them are heritage) Almost every TDM article organizes the field around three pillars, namely data masking, data subsetting, and synthetic data generation. Walking each one exposes how unequal they become the moment you change the buyer. ### Data masking and anonymization The technique swaps the identifying values in a real dataset, so names, account numbers, and phone numbers become realistic-looking substitutes and the data can travel outside production without exposing anyone. Approaches span deterministic tokenization, format-preserving encryption, generalization, and irreversible synthetic substitution. Getting it right is genuinely hard, and a sloppy job leaves enough residual signal to re-identify people from auxiliary data. All of it presupposes that someone, somewhere, has already decided production data needs to leave production. Where no team has made that call, masking is machinery pointed at a problem the org does not actually have yet. ### Data subsetting Subsetting takes a referentially consistent slice of a production database and ships it down to a lower environment. The relational cut is the hard part, since a sample of `users` drags in the matching `orders`, `order_items`, and `payments`, all of which must stay consistent so the slice does not arrive broken. Enterprise platforms pour real engineering into that, a genuine achievement that only becomes necessary once a team has committed to production as the source of its test data. ### Synthetic data generation Synthetic data gets built from scratch, and modern schema-aware generation does more than emit random strings; it produces values that fit each column's constraints while [respecting referential integrity automatically](/blog/referential-integrity). Deeper coverage lives in [generating realistic data straight from the schema with AI](/blog/generate-test-data-with-ai). Generation has stayed the smallest of the three pillars on enterprise SERPs for years, largely because it makes the other two unnecessary. ## What TDM looks like when production data is out of scope Picture a 30-engineer fintech in its third year. Its production database runs to 80 tables, ten under PCI scope and another dozen under general data-protection obligations, and the team ships weekly. Living in `db/seeds.sql` is a 1,400-line seed script that only three engineers understand in parts. Every other migration breaks it, the break surfaces in CI, and whoever is on call patches and commits the fix in a hurry. Over all of it, the compliance officer has warned that a future audit will look hard at staging. That is the production-out-of-scope posture in practice, a regulated stack held together by hand and never more than one schema change from a broken pipeline. Default TDM tooling assumes production is the source, so a team that rules production out has to make the rest of its stack follow. Schema-first generation addresses this directly. On every run, Seedfast reads the live schema and produces connected, valid values against the constraints of the moment, so the per-sprint maintenance loop around the seed script becomes an artifact the team regenerates on demand. Compliance does not disappear in this reframing; the work shifts from managing a production-to-staging pipeline toward proving that no production rows are in scope and that generated data never collides with a real record. ## Compliance, without the enterprise apparatus Mid-market teams in regulated industries answer to the same regulations as enterprise ones, just with leaner staffing and lighter process. The four regimes that come up most — [GDPR](https://gdpr-info.eu/), [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/index.html), [PCI DSS](https://www.pcisecuritystandards.org/), and [SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) — all push toward keeping sensitive production data away from development. Schema-first generation fits that posture, and [HIPAA-aligned test data for healthcare](/blog/hipaa-test-data) covers the healthtech case, though the honest claim is that good design _supports_ a compliance program without certifying it. Compliance stays a program-level outcome that TDM feeds into. ## Where TDM tooling sits today The tooling splits cleanly into four tiers (covered in depth in [the spectrum of data seeding tools](/blog/data-seeding-tools)). **The free / DIY tier.** Hand-written seed scripts, ORM seeders, and Faker-based factories live here, and it works fine while the schema is small and someone maintains it. Trouble starts once the schema grows, the team grows, or the migration cadence picks up. Most squads land here first, then [feel the maintenance compounding](/blog/seed-file-maintenance) and start looking elsewhere. **The web-generator tier.** Browser-based tools here emit CSV or SQL from column-level definitions, handy for a one-off dataset or a quick demo. Where they run out of room is the relational level, since most work one table at a time and hand the foreign-key wiring back to the user. **The schema-aware generator tier.** This small but emerging category is where both Tonic Fabricate and Seedfast sit, the first coming from the synthetic-data side and the second from a schema-first CLI, though they share the defining move of reading the schema and generating against it instead of asking a developer to describe the data shape by hand. What separates them is workflow, meaning where the schema is read from and how the tool drops into CI/CD. The catch is a young, thinly populated category whose database coverage trails the enterprise tier, with Seedfast PostgreSQL-only today. **The enterprise TDM tier.** Multi-product platforms cover masking, subsetting, virtualization, and provisioning under one roof. Pricing usually opens in the low-to-mid six figures a year, with deployments running for quarters. The fit is real for an organization that already moves prod data into staging and funds a compliance program to govern it. For the practical end of this spectrum, [what test data looks like at enterprise scale](/blog/enterprise-database-test-data) is the companion piece. Most teams without a prod-to-staging flow struggle to justify the enterprise tier but outgrow the DIY tier as the schema grows, so the live decisions sit in the middle two, where a regulated shop wanting CI-native generation lands at the workflow end. ## Test data management tools: what teams actually pick The four tiers describe the category's shape; the five tools below are the names that come up most often in 2026 TDM evaluations. Each row lines a tool up against the dimensions that decide fit, meaning its core approach, whether it ingests production data, database coverage, pricing, and deployment. | Tool | Primary approach | Production data flow required? | Database support | Pricing tier | Deployment | | --------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------ | | Seedfast | Schema-aware synthetic generation (live schema introspection) | No | PostgreSQL today | Startup tier, self-serve (free + paid) | CLI with SaaS backing | | Tonic Fabricate | Schema-aware synthetic generation (optional Live Connect to production) | No by default (Live Connect optional) | Postgres, MySQL, Oracle, Databricks, JSON/mock APIs | Free + Plus from $29/mo (usage credits, tonic.ai/pricing, as of Jul 2026); enterprise option | SaaS | | Datprof | Masking + subsetting + synthetic generation + virtualization | Optional | Postgres, MySQL, SQL Server, Oracle, Db2, others | Mid-market, quote-based | Self-hosted (also BYOL on AWS/Azure Marketplace) | | K2view TDM | Entity-based masking + synthetic + provisioning | Yes (typically ingests from source systems) | Multi-source enterprise (RDBMS, mainframe, APIs) | Enterprise (typically six figures+, quote-based) | Self-hosted platform | | Informatica TDM | Masking + subsetting + synthetic generation + data discovery | Yes (typical workflow includes production subsetting) | Multi-database, including mainframe | Enterprise (typically six figures+, quote-based) | IDMC cloud (legacy on-prem TDM end-of-life) | The table here is the SKU-level shortlist; [the spectrum of data seeding tools](/blog/data-seeding-tools) gives the narrative tour of each tier. Read the production-data-flow column closely, since it splits the tools into two camps. K2view and Informatica are built around production data as the source of test data, and those platforms make that flow safe and repeatable at scale. Tonic Fabricate and Seedfast default to schema-first generation, with Fabricate offering an optional Live Connect to production and Seedfast not ingesting production data at all, while Datprof supports both directions and suits teams keeping their options open. Picking the wrong camp costs more than waiting would, since you pay for capabilities you never use and inherit the assumptions baked into them. The database-support column is narrower than it looks inside the schema-aware tier. Both tools read the schema, but Seedfast is PostgreSQL-only today where Fabricate reaches Postgres, MySQL, and Oracle. A Postgres-native shop never notices the gap, whereas a multi-engine shop runs straight into it and wants the broader tool. Cutting the other way, Seedfast's CLI slots into a developer's existing habits, pointing at a connection string and describing a scenario to get rows back, while Fabricate's SaaS suits how a data-platform team already operates. A few adjacent tools come up often enough to mention without table rows. [Neosync](/compare/neosync-alternative) is open-source and schema-aware, covering masking and synthetic generation, with the linked piece going deeper. With a more AI-forward, data-as-code angle, Synthesized sits nearby. [Gretel AI](/compare/gretel-alternative) is Python-native synthetic and now part of NVIDIA, while Mockaroo is a lightweight self-serve tool whose schemas you define by hand, since it reads no live database. None of them changes the four-tier shape; they just add options, and the posture question still decides between them. ## Choosing your TDM approach: four decisions before you start Here is a short decision sketch, organized around the one question that decides the design space. **Is production data in scope at all?** When the answer is no, whatever the reason, masking and subsetting fall out of the design space, leaving the narrower question of how to generate good test data, which the schema-aware generator tier answers most directly. **How complex is the schema?** Foreign-key depth matters more than raw table count, so a 100-table schema with shallow relationships is often easier to seed than a 30-table one full of circular references. Past a handful of interrelated tables, hand-written scripts and Faker factories lose more time than they save, and schema-aware generation pays off with relational depth. **What is the CI/CD posture?** A team running tests on every PR and spinning up ephemeral environments many times a day has a different problem than one on a single shared staging database. The first needs generation that is fast, scriptable, and deterministic where it matters, while a shared-staging team tolerates slower workflows and cares more about how realistic the data looks on a dashboard. **What is the platform lead's tolerance for maintenance work?** This is the question the SERP almost never asks, though every approach carries a maintenance shape. Enterprise TDM keeps you maintaining masking rules. Hand-written scripts turn schema drift into the recurring tax, and web generators leave you re-pasting column definitions. Seedfast carries the smallest such burden, since it takes the schema the team already keeps current as its only input and asks for nothing else to be maintained. ## A test data management framework for in-house teams Once those four decisions are made, execution is a short repeatable practice, closer to a team habit than a procurement event. The version that holds up best in the production-out-of-scope posture has four parts. **Identify what each test actually needs.** Start from the shape of data each test requires (unit, integration, end-to-end, manual QA, perf), rarely the same as whatever production happens to contain. Most schemas have a few recurring scenarios (a logged-in user with two orders, a new signup mid-flow, a soft-deleted account) plus a long tail of one-off cases tied to tickets. Naming those scenarios is the first concrete deliverable. **Generate from the schema, not from the past.** With the scenarios named, the data each one needs can be generated against the current live schema, so nothing is copied from an old dump or hand-maintained in a seed file. That holds only when the generator re-introspects the live schema on every run, which the schema-aware tools above do, Seedfast and Tonic Fabricate among them. Other methods exist, and [the seven methods comparison](/blog/test-data-generation) covers them. **Cadence has to match velocity.** A team shipping several times a week wants test data that regenerates per PR or per ephemeral environment, while one shipping weekly or slower can regenerate per merge or per deploy. The question worth asking is how often the pipeline breaks once the seed sits stale past a given interval, since that interval is the real ceiling. Set a cadence longer than your migration cadence and you land back in the broken-seed-in-CI loop this framework removes. A CI-native run on each PR keeps the two aligned, and [the CI/CD database seeding guide](/docs/cicd-database-seeding) covers the wiring. **Govern by design rather than by process.** When production data never enters the pipeline, most of what a governance program needs to assert about test environments (no real PII, no real account numbers) is structurally true rather than procedurally enforced. The audit trail becomes concrete, since the team generates from the schema and keeps the configuration and its commit history in the repo. That moves the governance burden out of an ongoing process and into a one-time architectural choice plus the usual config-change review, generally the cheaper place to spend the effort. Taken together, the four parts are really a set of questions every TDM tool answers in its own way, and ones a team building in-house answers for itself. For teams keeping production out of scope, one tool shaped for that answer is [Seedfast](https://seedfa.st/). Point its Postgres-native CLI at a connection string, describe the scenario, and it fills the database with connected relational data without a hand-written factory file. [Pricing is published](/pricing), and the free plan covers a small schema end-to-end. ## Test data management FAQ ### What is the difference between TDM and database seeding? Seeding is a single move inside the wider discipline of TDM. A seed drops a starting set of rows into a database, whereas TDM decides where that data comes from in the first place and how it stays trustworthy as the schema and the team change. The nightly seed is just one thing that strategy sets in motion. ### What are the core concepts and components of TDM? The traditional list names three components, data masking, data subsetting, and synthetic data generation, with data virtualization sometimes added as a fourth. How much each matters depends on whether production data is in scope. A shop routing prod data into dev cares about all of them, while a team generating from the schema usually needs only generation. ### Do small teams need TDM? Any group that runs tests against a database is already doing some form of test data management, whether or not they name it. What varies is how deliberate the approach is. A tangled seed file that no one owns is TDM by accident, while the same discipline done on purpose looks like a schema-first generator wired into CI. Small teams usually feel that gap the first time a migration silently breaks their fixtures. ### Is synthetic data the same as TDM? Synthetic data is one technique TDM can draw on, so the two are not the same thing. A thoughtful TDM strategy might use none of it, as with an enterprise group that only masks production subsets. The reverse happens just as often, a shop leaning hard on synthetic data while barely thinking about test-data strategy. ### What is the difference between TDM and data anonymization? Anonymization transforms real records so the identifying information is gone. It sits inside TDM, the strategy that decides when and what to anonymize, where the anonymized data lives, and which other techniques (subsetting, generation, virtualization) work alongside it. ### Does choosing schema-first generation rule out adopting masking and subsetting later? Schema-first generation and enterprise masking coexist without much friction. Teams that add enterprise-style masking and subsetting later usually keep schema-first generation for their unit and integration tests, since the two solve different jobs. Choosing schema-first today commits you to a workflow and leaves the door open to heavier machinery later. ### What is the best test data management tool for teams that do not pull from production? Among the schema-aware generators that need no production-data flow, the practical 2026 shortlist comes down to Tonic Fabricate (multi-database SaaS), Seedfast (PostgreSQL-native CLI), Neosync (open-source, masking plus synthetic), and Mockaroo (lightweight self-serve). Which one counts as best turns on database coverage, deployment shape, and CI fit, all mapped in the comparison table above. ### How do I implement test data management without enterprise tools? Run the four-part framework above against your own stack. Name the test scenarios, generate them from the live schema while keeping production out of the loop, refresh on a cadence tied to how often you migrate, and let the architectural choice carry the governance work. In practice that is a CLI invocation in a CI workflow plus a small set of scenario definitions in the repo. ### What is a test data management framework? It depends on who is asking. To an enterprise buyer, a TDM framework is a vendor product bundling masking, subsetting, generation, and provisioning into one workflow. For an in-house team it is looser, the working set of decisions about sourcing test data, keeping it current, and governing it, made deliberately or simply fallen into. The four-part version here is the in-house kind. ### What is the difference between TDM and master data management (MDM)? The two get confused mainly because the acronyms rhyme. TDM governs the disposable data moving through test, dev, and staging, aiming for inputs that are safe to use and current with the schema. The canonical reference records a business runs on (customers, products, locations) belong to MDM, which exists so production systems share one trusted source. They touch occasionally, since an MDM rollout tends to trigger TDM work in pre-prod, but they answer to different teams. ## Related guides - [Test data generation: seven methods compared](/blog/test-data-generation) — the deeper take on generating test data, from SQL files and fakers to schema-aware synthetic generation, and what "synthetic" means once it is more than random strings. - [What test data looks like at enterprise scale](/blog/enterprise-database-test-data) — the practical companion piece for teams looking at the other end of the spectrum. - [Review SQL migrations in 30 seconds](/blog/migration-review) — using realistic test data to catch what a migration does to your data before you approve the PR. - [Get started with Seedfast](/docs/seed-your-database) — point a CLI at a connection string, describe the scenario you need, and get back a populated database. --- # Test Data for Healthcare Apps: What Your Database Actually Needs > Source: https://seedfa.st/blog/test-data-for-healthcare > Last updated: 2026-07-22 **This page covers the relational and temporal shape healthcare schemas need.** If you're here for the HIPAA compliance angle (§164.514 de-identification, what counts as PHI in a Postgres schema, and keeping dev or staging out of HIPAA scope), see [HIPAA test data](/blog/hipaa-test-data). > **TL;DR:** Test data for healthcare apps needs three things a generic generator won't produce: valid foreign-key chains (patients → encounters → prescriptions), temporal ordering so a prescription never predates its encounter, and cross-row trajectories like an HbA1c series that descends across visits. Compliance rules are a separate concern. ## Key Takeaways - Healthcare schemas carry temporal and relational dependencies that generic test data tools miss: a prescription must follow a diagnosis, lab results must reference a specific encounter, and visit patterns need to reflect clinical reality - Cross-row temporal consistency separates useful healthcare test data from rows that merely fill tables: visit clustering during acute episodes, prescription chains that follow diagnoses, lab values that track physiological trajectories - Seedfast reads the live Postgres schema behind a connection string and writes the patients → encounters → prescriptions chain connected, dating each prescription from the encounter it hangs off, with no production database in the run Healthcare applications rarely fail on bad data in obvious ways; more often a prescription references an encounter that hasn't happened yet, or lab results turn up for a patient with no visit history. What makes healthcare test data hard isn't volume, but the structural dependencies that most generation approaches ignore. This article covers what realistic healthcare test data looks like at the relational schema level, not which regulation requires it (that's a [separate guide](/blog/hipaa-test-data)), and not what counts as a PHI field. ## Why healthcare data is structurally different Healthcare databases carry temporal dependencies between tables that don't exist in most application domains, since an encounter references a patient and a date, and any prescription tied to it must have been written on or after that encounter's date. Lab results reference the same encounter too, carrying values that, in a realistic dataset, track a physiological trajectory across multiple visits, while insurance coverage records carry effective dates that have to overlap with the encounters they cover. These aren't exotic edge cases; they're the baseline shape of healthcare data, and most [test data generation methods](/blog/test-data-generation) don't account for them. ## What a healthcare database schema looks like To make this concrete, here's a simplified version of what a healthcare app's relational schema looks like, drawn not from a reference architecture but from the tables a typical telehealth or clinic management platform ends up with after six months of development: ``` patients ├─► encounters (patient_id FK, visit_date, type, provider) │ ├─► prescriptions (encounter_id FK, medication, dosage, start_date) │ ├─► lab_results (encounter_id FK, test_name, value, units, result_date) │ └─► diagnoses (encounter_id FK, icd_code, description, diagnosed_at) └─► insurance_coverage (patient_id FK, plan_name, effective_start, effective_end) ``` This schema comes down to five tables, four foreign keys, and a set of implicit temporal rules that no FK constraint can encode. The database enforces that a prescription belongs to an encounter (`encounter_id` is a valid FK), but nothing in the schema enforces that the prescription's `start_date` falls on or after the encounter's `visit_date`, that lab results show a plausible progression across visits, or that insurance coverage periods overlap with the encounters they're supposed to cover. This is where healthcare test data diverges from generic approaches, and where the gap between [test data for PostgreSQL](/blog/test-data-postgresql) and truly domain-aware generation becomes visible. FK structure is the foundation. On top of it sits a layer of domain rules that are implicit in any real patient record but absent from the schema's constraint definitions. And this is a five-table simplification. Production telehealth platforms add providers, appointments, referrals, and billing tables, each with more FK edges and more implicit temporal rules. By the time a schema reaches 20 tables, the cross-table dependency web is dense enough that hand-rolling test data becomes its own engineering project. ## Where column-level generators break Faker, Mockaroo, and similar column-level generators are good at producing realistic-looking values for individual fields: patient names that look like names, medication fields containing real medication names, dates that fall within a plausible range. Where they break is at the row level. Each prescription row gets a random medication, a random dosage, and a random date, with no awareness that this prescription is supposed to be connected to a specific encounter for a specific patient on a specific date. Every constraint passes, with FK values valid, NOT NULL columns filled, and CHECK constraints satisfied, but the dataset still doesn't test anything meaningful, because relationships between rows don't reflect how healthcare data actually behaves. Consider a simple test scenario: a patient with Type 2 diabetes. In a realistic dataset, you'd expect an initial encounter with a diabetes diagnosis, followed by a metformin prescription, followed by quarterly lab results tracking HbA1c levels, followed by dosage adjustments if the levels don't improve. That sequence spans four tables and ten or fifteen rows, all temporally ordered and clinically linked. Column-level generators produce rows in all four tables, but the diabetes diagnosis might appear six months after the metformin prescription, and HbA1c values might land as random floats between 0 and 100 rather than plausible readings between 4.0 and 14.0. Dosage adjustments, meanwhile, might precede the lab results that would have motivated them. Data fills the schema but doesn't exercise the application. For background on why [generating test data from a schema](/blog/test-data-generation) avoids this class of problem, that guide covers the mechanics in detail. ## Where file-based tools leave you stranded Synthea, the most widely used open-source tool for synthetic patient data, addresses the clinical realism problem well, simulating patient life histories with disease progressions, medication regimens, and encounter sequences that follow clinical logic. The records that come out are temporally consistent and medically plausible, which makes Synthea a strong choice for research and analytics. Where it falls short is format. Synthea exports [HL7 FHIR](https://hl7.org/fhir/) bundles, C-CDA documents, and flat CSVs, but doesn't connect to your database or know about your `encounters` table, your `prescriptions` table, or your custom `insurance_coverage` schema. What you get is standardized healthcare data in a standardized format, not data shaped to your application's relational model. Public healthcare datasets run into the same problem, since [CMS Synthetic Public Use Files](https://www.cms.gov/Research-Statistics-Data-and-Systems/Downloadable-Public-Use-Files/SynPUFs) provide claims-level data in fixed-width or CSV format, and [MIMIC-III](https://physionet.org/content/mimiciii/) and [MIMIC-IV](https://physionet.org/content/mimiciv/) offer rich clinical datasets for research, valuable for data science and ML training, but not designed to populate a development database with a custom schema. Bridging this gap is a real engineering project: downloading a dataset or generating Synthea output, then writing an ETL pipeline to transform it into your schema, mapping FHIR resources to your table structure, handling FK ordering, and updating the pipeline every time your schema changes. Teams that attempt it often find the pipeline needs revisiting after every migration, and the maintenance cost quietly accumulates. When it comes to test data for healthcare applications, the actual need isn't a dataset but data that fits their schema, and that difference between a downloadable CSV and a populated relational database with valid relationships is the same gap that separates [staging with production copies from staging without them](/blog/staging-without-prod-data). ## The best test data generator for healthcare: what to look for The best test data generator for healthcare populates _your_ tables, not a research export you reshape, with cross-row temporal and foreign-key integrity intact, and never touches production patient data. Those three criteria, in that order, separate a tool that exercises your application from one that merely produces healthcare-shaped rows somewhere else. Run the three approaches against those criteria and the choice gets concrete: - **Synthea** has the best clinical realism by far: full synthetic patient histories with plausible disease progressions. But it exports FHIR, C-CDA, and CSV (FHIR bundles by default), not your schema, so using it means owning the ETL pipeline that maps those resources onto your `encounters` and `prescriptions` tables and re-runs after every migration. It's an excellent dataset, just the wrong shape for an app database. - **Column-level generators** (Faker, Mockaroo) are fast and fill every cell, but they have no cross-row awareness: a prescription can land before its encounter, lab values come out as independent random floats. That works for a flat table; it falls apart across a patient timeline. - **Schema-aware generators** read your live schema and produce the `patients → encounters → prescriptions → lab_results` chain fully connected, with temporal coherence. Tonic Fabricate is one, generating from scratch through a web/chat agent (it can connect to a live database or work from a described schema), with token-metered pricing (Plus at $29/month plus per-turn metering of roughly $0.17 to $0.37 per turn beyond included credits, as of July 2026, per Tonic's pricing page). Seedfast reads the live Postgres schema from a connection string and writes that chain in one CLI or CI command, billed against a flat monthly plan that includes a pool of credits. For the buyer who _cannot use production patient data at all_, blocked by policy or regulation from copying patient records, that is the whole decision: a research dataset you must reshape, a column generator that breaks at the row level, or a schema-aware generator that builds your tables directly with no patient record ever read. If your question is really about the regulation rather than the shape of the data, the [HIPAA test data](/blog/hipaa-test-data) guide covers what §164.514 requires; the broader cross-framework view is in [compliant test data](/blog/compliant-test-data). For the full Postgres tool field beyond healthcare, see the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison and the regulated-team [data seeding tools](/blog/data-seeding-tools) pillar. The financial-services counterpart to this page is [test data for fintech](/blog/test-data-for-fintech). ## Cross-row consistency: what makes healthcare test data useful This is the piece that neither column-level generators nor file-based tools address for your specific schema: cross-row temporal consistency. Realistic healthcare data means more than valid foreign keys and plausible column values; patient timelines have to behave like patient timelines. **Visit patterns cluster.** During stable periods, someone with a chronic condition visits quarterly, shifting to weekly during acute episodes. Encounters should reflect this: not uniform random dates spread across the year, but clusters and gaps that mirror how patients actually interact with a healthcare system. **Prescription chains follow diagnoses.** After a lipid panel with elevated LDL comes a statin prescription; after an encounter with an infection diagnosis comes an antibiotic one, its start date tracking the encounter date and any refill following the previous prescription's end date. These aren't random associations; they're sequences with causal and temporal order. **Lab values track trajectories.** Across six months, blood glucose readings should show a pattern, improving after a medication change, gradually worsening if untreated, or holding stable during maintenance therapy. Random float values in the `lab_results` table don't test the application logic that flags deteriorating trends or triggers alerts on out-of-range values. **Insurance coverage overlaps with encounters.** If a patient has a gap in coverage, their encounters during that gap should be either absent or flagged differently. Datasets where every patient has continuous coverage and visits distributed uniformly don't exercise the edge cases that billing and eligibility logic needs to handle. Cross-row consistency is what turns test data for healthcare into something that exercises your application's logic instead of merely filling rows. ## What the generated data actually looks like Claims are easy to make, so here's what cross-row temporal consistency looks like in practice. Take the diabetes example from above: initial diagnosis, metformin prescription, quarterly HbA1c labs, then a dosage adjustment when levels don't improve. A Seedfast run with the scope `"telehealth app, 200 patients, 6 months of encounter history, mixed chronic and acute cases"` produced this patient timeline: ```sql -- Patient encounters, ordered by visit date SELECT e.id, e.visit_date, e.type FROM encounters e WHERE e.patient_id = 47 ORDER BY e.visit_date; ``` ``` id | visit_date | type -----+------------+----------- 201 | 2024-03-12 | initial 208 | 2024-06-18 | follow-up 215 | 2024-09-24 | follow-up 223 | 2024-12-10 | follow-up ``` ```sql -- Diagnosis and prescriptions tied to each encounter SELECT d.diagnosed_at, d.icd_code, d.description, p.medication, p.dosage, p.start_date FROM diagnoses d JOIN prescriptions p ON p.encounter_id = d.encounter_id WHERE d.encounter_id IN (201, 208, 215, 223) ORDER BY d.diagnosed_at; ``` ``` diagnosed_at | icd_code | description | medication | dosage | start_date --------------+----------+----------------------+-------------+---------+------------ 2024-03-12 | E11.9 | Type 2 diabetes | Metformin | 500mg | 2024-03-12 2024-09-24 | E11.9 | Type 2 diabetes | Metformin | 1000mg | 2024-09-24 ``` Diagnosis date and prescription `start_date` match the encounter `visit_date`, not by coincidence or a lucky seed, but because Seedfast wrote each prescription against the encounter it belongs to and took its date from there. The dosage adjustment on the third visit appears only after two prior encounters, the way a real clinical workflow would produce it. ```sql -- HbA1c trajectory across the four visits SELECT lr.result_date, lr.value, lr.units FROM lab_results lr WHERE lr.encounter_id IN (201, 208, 215, 223) AND lr.test_name = 'HbA1c' ORDER BY lr.result_date; ``` ``` result_date | value | units -------------+-------+------- 2024-03-12 | 8.7 | % 2024-06-18 | 8.2 | % 2024-09-24 | 7.8 | % 2024-12-10 | 7.1 | % ``` Values descend across visits, consistent with a patient responding to metformin therapy, not four random floats sampled independently. The application logic that checks for improving HbA1c trends, or that flags a patient who isn't improving after two quarters, now has data that will actually exercise those code paths. This is what column-level generators can't produce and Synthea doesn't target: data shaped to your specific schema, with temporal relationships intact. ## Generating test data for your healthcare schema [Seedfast](https://seedfa.st/) reads your PostgreSQL schema and produces records with these relational properties, removing the need for hand-rolled orchestration or production data access: no FHIR-to-SQL pipeline to write, no seed script to update after the next migration, and no production patient record ever read. Seedfast does still send your schema definition (table and column names, types, constraints) to an AI provider to generate the data — for what that means in a regulated shop, see the [HIPAA guide](/blog/hipaa-test-data). When the team adds a `referrals` table next sprint, the next run sees the new table in the database and fills it, with no pipeline code to revisit. If your team is also under HIPAA, [the compliance angle is here](/blog/hipaa-test-data). The realism developers need carries over to [test data management](/blog/test-data-management) across local, CI, and staging environments. Try it on your own schema, [get started here](/docs/seed-your-database), or start on the free plan (see [pricing](/pricing)): ```bash seedfast seed --scope "telehealth app, 200 patients, 6 months of encounter history" ``` ## Frequently asked questions ### What does healthcare test data need that generic test data doesn't? Generic generators fill in plausible values column by column, but healthcare schemas need three properties layered on top: foreign-key chains that stay valid across `patients`, `encounters`, `prescriptions`, and `lab_results`; temporal ordering, so a prescription's `start_date` never predates its `encounter.visit_date`; and cross-row trajectories, like an HbA1c series that descends across visits as a patient responds to therapy. Fill the columns without those three, and the rows still won't behave like a real patient timeline. ### How are generic test data tools different from healthcare-specific ones? Generic tools like Faker and Mockaroo generate plausible column values but ignore cross-row temporal and FK dependencies: a prescription can land before its encounter, lab values are random floats, and free-text columns get lorem ipsum. Seedfast writes the patients → encounters → prescriptions chain with its dates and FK references coherent, and fills contact columns from ranges reserved for fictional use (TEST-NET IPs, 555-01XX phones, the `example.test` domain), so no generated record is confusable with a real person. ### Can I use Synthea to populate my healthcare app's database? Synthea generates clinically plausible patient histories in HL7 FHIR, C-CDA, or flat CSV, formats designed for research, not for a custom application schema. To use Synthea output in your dev database, you write an ETL pipeline that maps FHIR resources onto your `encounters`, `prescriptions`, and `lab_results` tables, handles FK ordering, and re-runs after every schema migration. The clinical realism is excellent, but turning that output into rows your own schema can use is the part that becomes its own engineering project. ### What does a healthcare database schema typically look like? A telehealth or clinic-management schema typically has five core tables: `patients`, `encounters` (with `patient_id`, `visit_date`, `type`, `provider_id`), `prescriptions` (with `encounter_id`, `medication`, `dosage`, `start_date`), `lab_results` (with `encounter_id`, `test_name`, `value`, `units`, `result_date`), `diagnoses` (with `encounter_id`, `icd_code`), and `insurance_coverage` per patient. Production systems add providers, appointments, referrals, and billing, so the schema quickly reaches 20+ related tables. ### What is the best test data generator for healthcare? For populating an application database, the best test data generator for healthcare is one that fits your schema, keeps cross-row temporal and FK relationships intact, and never reads production patient data. Synthea has the strongest clinical realism but exports a research format you must ETL into your tables, while column-level tools like Faker and Mockaroo fill cells without row-level coherence. Schema-aware generators (Tonic Fabricate, Seedfast), by contrast, build the `patients → encounters → prescriptions` chain directly from your schema, and the split between them is workflow: a web chat-UI with token metering versus a live-schema CLI on a flat monthly plan. ### Can I use synthetic data for healthcare testing instead of real patient data? Yes. Synthetic data for healthcare testing that is generated from your schema never derives from a real patient record, so it contains no PHI and no real person sits behind any row. Seedfast runs against your own database and requires no production access, which is what makes generated data the practical default for development, CI, and demos once policy or regulation blocks the real thing. The regulatory specifics of why it stays out of HIPAA scope are covered in the [HIPAA test data](/blog/hipaa-test-data) guide. ## Related guides - [HIPAA compliant test data](/blog/hipaa-test-data), the regulatory side covering what §164.514 requires and why no-PHI generation keeps dev and staging out of HIPAA scope. - [Referential integrity in test data](/blog/referential-integrity), why FK ordering is the foundation problem for multi-table data generation - [Staging without production data](/blog/staging-without-prod-data), the broader case for generating from schema instead of cloning production into lower environments - [Test data generation methods](/blog/test-data-generation), the full menu of approaches from column-level tools to schema-aware generation, and when each one fits - [Get started with Seedfast](/docs/seed-your-database), connect to your PostgreSQL database and generate your first schema-aware dataset --- # Seed the Parent Branch, Not Every PR: Neon Branching Meets Seed Data > Source: https://seedfa.st/blog/neon-branching-seed-data > Last updated: 2026-07-22 _Neon branches copy their parent in milliseconds, a property that either saves you from the seeding problem entirely or drops a subtler one in your lap. Here's how to tell which situation you're in, and what to do in each._ ## Key Takeaways - Neon branches inherit their parent's data via copy-on-write, so the seeding question is not "how do I seed every branch" but "how do I keep one parent populated well enough that its branches are" - [Seedfast](https://seedfa.st/) fills that parent in a single run and works from the schema it finds there rather than a committed file, so per-PR seeding costs O(1) instead of scaling with the number of open PRs - The pattern breaks only on two kinds of branches, schema-drift PRs (migration adds a `NOT NULL` column the inherited rows don't satisfy) and schema-only branches (no data copied at all) - Both exceptions need data generated against the branch's own live schema, because a static `seed.sql` describes a shape that branch no longer has and a hand-written `seed.ts` needs an edit for every migration - Point Seedfast at any branch and it generates a valid, connected dataset from a plain-English scope against the schema that branch has right now. Parent-seed, drift-reseed, and schema-only rescue are the same command with a different target Having provisioned Neon for the branching, you get a fresh, full-fat database on every pull request in about a second, copied from the parent branch and ready for CI. The catch is that "copied from the parent" only solves the seeding problem if the parent is actually seeded, and only holds while the branch schema still matches what was in the parent. Three Neon branching seed data patterns cover that full workflow, and this guide walks through all three, parent-once, drift-reseed, and schema-only rescue. If you haven't read the fundamentals yet, [how to seed a Neon database](/blog/seed-neon-database) covers the raw SQL, Prisma, and Drizzle mechanics that this article builds on. This one is specifically about how branching changes the seeding model. ## How Neon branching actually copies data Neon's branches are copy-on-write, so when you create a branch from `main`, the child branch points at the same storage as the parent and no bytes are duplicated until one side writes. Reads against the child see the parent's data instantly, while writes on either side diverge from that moment forward. That model is the whole reason branching is fast. Because no copying happens up front, Neon's own numbers put branch creation at roughly one second regardless of database size, whether you're inheriting one terabyte or one row. Two flags at branch creation decide how much you inherit: - **Full branch** copies schema and data, the default that preview branches for PRs usually want. - **Schema-only branch** copies the schema but leaves the data out, which is the choice when the parent holds sensitive data you can't replicate to every PR, such as production-like staging, a healthcare dataset, or anything under an existing compliance lane. Full branches make the seeding problem disappear for most PRs, while schema-only branches reintroduce it in a narrower but harder form that most teams eventually run into as well. The same inheritance now decides what AI agents work against. Neon's branch-per-agent-session pattern spins up a branch for every agent run, and each of those branches is born holding exactly what the parent held, so a seeded parent hands every agent a populated sandbox while an unseeded one hands every agent the same emptiness to improvise against. [Which database an agent should touch at all](/blog/ai-agent-production-database) is its own question; on Neon, what the agent finds inside is decided at the parent. ## Pattern 1: Seed the parent once, let preview branches inherit Neon branching seed data gets much simpler once you notice that you almost never need to seed a branch at all. Seed the parent instead, usually `main`, `dev`, or a dedicated `seed` branch, and every branch forked from it comes up populated. Keeping that parent current across schema changes is the part that decides whether the pattern survives a quarter of migrations, and Seedfast rebuilds the parent dataset from whatever schema the latest migration left behind, so branches keep inheriting rows that still fit their tables. ```bash # Seed the parent once, from your local machine or a manual CI job seedfast connect seedfast seed --scope "realistic e-commerce: 3 orgs, 200 users, 1,000 orders across 50 products" ``` Now the preview workflow has no seeding step at all: ```yaml # .github/workflows/preview.yml name: Preview branch per PR on: pull_request: jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Create Neon branch from main id: neon uses: neondatabase/create-branch-action@v6 with: project_id: ${{ secrets.NEON_PROJECT_ID }} branch_name: preview/pr-${{ github.event.pull_request.number }} parent_branch: main database: ${{ secrets.NEON_DATABASE_NAME }} role: ${{ secrets.NEON_ROLE_NAME }} api_key: ${{ secrets.NEON_API_KEY }} - name: Run migrations on the new branch run: npx prisma migrate deploy env: DIRECT_URL: ${{ steps.neon.outputs.db_url }} - name: Run E2E tests run: npm run test:e2e env: DATABASE_URL: ${{ steps.neon.outputs.db_url_pooled }} ``` Specify the `database` and `role` inputs when your Neon project uses non-default names. Without them the action falls back to `neondb` / `neondb_owner`, and if those don't exist on your project, the output DSNs point at credentials Postgres will refuse to connect with, the kind of silent misconfiguration that shows up only when the test step runs. Otherwise there's no seed step at all, because the branch is already populated; a typical preview pipeline runs in the time it takes to deploy the app, and the database ends up being the cheap part of it. This pattern works until either a PR introduces a migration that changes the schema in ways the inherited rows can't satisfy, or your security review forces schema-only branching. Both cases are the rest of this article. ## Pattern 2: Schema-drift branches — reseed only on the PRs that changed the schema A developer opens a PR that adds `ALTER TABLE orders ADD COLUMN fulfillment_provider TEXT NOT NULL`, the branch gets created from `main`, and the 1,000 inherited orders have no `fulfillment_provider` to satisfy it. The migration fails outright, or worse, it doesn't, because someone added a default, and now your tests are running against orders where every provider is the same literal string. You don't want to reseed every branch (that defeats the O(1) advantage); you want to reseed only the branches where the inherited data no longer matches what the schema expects. A simple heuristic is "does this PR touch the migrations directory?" ```yaml # .github/workflows/preview.yml (excerpt) - name: Check if PR changes migrations id: migrations uses: tj-actions/changed-files@v46 with: # Adjust the glob list to match your migrator. Cover every location # where schema changes can land — Prisma, Drizzle, raw SQL, Flyway, # Liquibase, custom folders. A missed path here means CI silently # runs against stale inherited data. files: | prisma/migrations/** drizzle/migrations/** db/migrations/** supabase/migrations/** **/*.sql - name: Run migrations on the new branch run: npx prisma migrate deploy env: DIRECT_URL: ${{ steps.neon.outputs.db_url }} - name: Reseed branch for schema-drift PRs if: steps.migrations.outputs.any_changed == 'true' run: npx seedfast seed --scope "e2e baseline: 3 orgs, 20 users, 100 orders" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: ${{ steps.neon.outputs.db_url }} ``` The reseed step only runs on branches that changed a migration file. The glob list above is the most brittle part of the workflow, because if your team commits migrations somewhere it doesn't cover, CI silently runs against stale inherited data and green builds can ship breaking schema changes. Treat the list as a contract with your migrator and update it whenever that surface changes. The Seedfast CLI flags used here (`--scope`, `--output`, `SEEDFAST_API_KEY`, `SEEDFAST_DSN`) are covered in the [CI/CD database seeding docs](/docs/cicd-database-seeding). What makes the step correct on a drift PR is when it runs. Seedfast reads the branch after `prisma migrate deploy` has finished, so it generates against the schema the migration just produced rather than the one `main` still has. [Connect your Neon project](/docs/seed-your-database) and try the Pattern 2 step on a throwaway PR before you wire it into the real workflow. Two failure modes the glob heuristic does not catch: (a) business-logic PRs that need different data even though no schema changed, which need a manual reseed trigger on the parent branch; (b) schema changes that happen via side effects (e.g., a PR that runs a one-off SQL script from application code rather than a migration file). For (b), the only durable fix is "all schema changes go through the migrator," which is a team discipline issue, not a CI one. This is the pattern where a static `seed.sql` stops working. The file describes the old schema, the one that existed in `main`, while the new branch's schema carries a new column, a new table, or a renamed foreign key the file knows nothing about. Nobody updates it until something breaks, and that job usually falls to whoever is trying to get their own PR to green. What separates reseeding-on-drift from rewriting the seed file inside the PR is that the generator adapts to the schema it finds at run time instead of assuming yesterday's shape. [Why static seed files break](/blog/seed-file-maintenance) covers the lifecycle in full; on Neon, the drift hits earlier because you feel every migration on every preview branch. ## Pattern 3: The schema-only branch rescue Schema-only branching is Neon's answer to "we can't copy that data to every PR." The parent database might hold records covered by a regulatory scope your team can't replicate to every developer's preview, or sensitive payment metadata that staging can't expose outside its access boundary; sometimes the dataset is simply too large to copy a hundred times a day regardless of what it contains. Schema-only branches inherit the structure (tables, columns, indexes, foreign keys) and nothing else. Seedfast itself generates synthetic rows and holds no compliance certification of its own; the fit here is that synthetic data replaces the copy, and whatever compliance posture your team already operates continues to apply. That leaves you at an empty database with a real, complex schema in front of it, the seeding problem in its purest form. The traditional answers are bad in this specific case: - **A committed `seed.sql`** goes out of date the day after the next migration lands, and doubly so on a schema-only branch where no inherited rows mask the gap. See [seed file maintenance](/blog/seed-file-maintenance) for the full argument. - **A Prisma or Drizzle `seed.ts`** does better than raw SQL, but it's still hand-written against a schema that keeps changing under it, so every new FK becomes a hand-edit. Seedfast reads that same schema at the moment the command runs, which is why the new FK costs nothing on its side. - **Copy-and-anonymize from production** is the right answer for some teams, but it's heavy, since you end up running a full data pipeline just to fill a CI database. - **Faker libraries** generate values but not relationships, so a 20-table schema becomes 20 one-table seed scripts with manual FK wiring, a pattern that stops scaling around table ten on any real schema. [Seedfast](https://seedfa.st/) reads the branch's live schema on each run and generates rows that fit it, valid and connected, with FKs pointing at real parents. You describe the dataset in a plain-English scope: ```bash # On the schema-only branch export DIRECT_URL="postgresql://...ep-xxxx.region.aws.neon.tech/db?sslmode=require" seedfast seed --scope \ "small SaaS org: 2 workspaces, 15 members, 100 projects with 500 tasks and activity history" ``` There's no `seed.sql` and no `seed.ts`. Seedfast connects, picks up whatever the last migration added, and writes rows that fit it, with every foreign key pointing at a parent that exists. If the schema changes next week, the same command still works next week. Self-referential tables (`employees.manager_id → employees.id`) come out populated too, and looped foreign-key pairs work wherever the schema leaves one side of the loop nullable. Seedfast is the same binary in all three positions, which is why teams end up running the whole Neon workflow through one command rather than three tools duct-taped together. Seedfast is meant to coexist with the small set of deterministic fixtures most apps rely on, such as the `admin@example.com` account your Playwright login spec types, the feature-flag rows keyed by `key`, or the country-code lookup table. Keep those in a short `fixtures.sql` or a trimmed-down `seed.ts` and run them after `seedfast seed`. Seedfast fills the bulk relational data around them; your fixture file pins the specific records your tests expect by literal value. The two layers compose cleanly because Seedfast writes rows through the same Postgres connection your fixture script uses, so there's no tool-specific state to reconcile. Trying Seedfast against any Neon connection string is free, and you can [run your first seed](/docs/seed-your-database) in about two minutes. ## Reset a branch to its parent: the fast-feedback loop Sometimes a branch gets polluted mid-test, whether a destructive migration runs, a test accidentally writes real data, or a developer just wants to start over. Neon supports resetting a child branch to its parent, a one-call operation via the API or dashboard that drops the branch's divergent writes and restores parent state. ```bash # Restore a branch to the latest state of its parent (main) curl --request POST \ --url "https://console.neon.tech/api/v2/projects/$PROJECT_ID/branches/$BRANCH_ID/restore" \ --header "Authorization: Bearer $NEON_API_KEY" \ --header "Content-Type: application/json" \ --data '{"source_branch_id": "'"$PARENT_BRANCH_ID"'"}' ``` The reset takes about a second for the same reason branch creation does, since no data is copied, just a pointer update. That makes "reset to parent, rerun tests" a tight feedback loop when you're debugging flaky tests that might be polluting each other. Combined with Pattern 1 (parent already seeded), you get an instant clean environment every time. One caveat is that reset-to-parent restores the branch's state to the parent's current state, which means if the parent has not had the PR's migration applied (it usually hasn't), the next CI run will re-apply the migration and overwrite whatever you reset to. The loop is "clean environment for test-data pollution," not "clean environment for migration rollback." For migration-drift branches in Pattern 2, the correct recovery is to delete and recreate the branch rather than reset, since reset will immediately be undone by the next `prisma migrate deploy` step. If the parent itself is drifting out of shape (say, `main` has ten-month-old orders that don't reflect the current product schema), the fix is reseeding the parent, not resetting branches. `seedfast seed` is idempotent against a freshly-truncated schema, so the operational pattern is to truncate the target tables, then run `seedfast seed` with the current scope. ## Cost framing: why seed-every-branch is a billing mistake Neon bills compute seconds per branch, and a branch that runs a seed for the length of the seed is billing the whole time it runs. For a concrete team, the number depends on how long your seed actually takes: a short reference-data seed takes seconds, while a realistic-volume seed with FK chains takes minutes. What stays fixed is the shape of the bill. Seed every PR, and the line scales linearly with PR volume. Seeding the parent once and letting branches inherit replaces that line with near-zero CI compute on most PRs. The parent-seed compute happens once after each schema change on `main`, usually as a scheduled job or a manual workflow dispatch, and it doesn't run for every PR that opens. Preview branches pay the compute cost of their actual tests, nothing more. If your team moves fast on migrations, "once after each schema change" still means a handful of parent reseeds per week, but each one replaces dozens of per-branch seeds that would otherwise run on every PR that opened during the same week. When schema drift forces a reseed on a specific branch, you pay one seed's worth of compute for that one branch. That's the right bill; you asked the database to do work because the schema changed. The only real waste is paying for that seed on every PR, including the 90% that never touched migrations, and the parent-once pattern is what eliminates that line entirely. ## Parallel tests, branch limits, and other gotchas **Neon branch caps.** Every Neon plan has a branch limit (Free plans are tighter than paid), and PRs that sit open for weeks quietly accumulate branches against it. The [delete-branch-action](https://github.com/neondatabase/delete-branch-action) on `pull_request: closed` is required, not optional, because skipping it means you'll hit the cap mid-PR while the preview workflow fails at branch creation. **Parallel test isolation.** If you run Jest or Playwright in parallel and all workers hit the same preview branch, they race for the same rows. For true isolation, create one branch per worker at the start of the test run and delete them at the end. Six workers with one-second branch creation add up to six seconds of overhead, which is vastly cheaper than running six separate seeded databases. Every worker also brings a pool of its own, and the ceiling those pools share scales with the compute size behind the branch, which [the pool size calculator](/tools/postgres-connection-pool-calculator) turns into a per-worker number. **Pooled vs unpooled for seeding.** The parent-seed and drift-reseed jobs need the **unpooled** connection string, because PgBouncer in transaction mode breaks prepared statements and can time out on large seeds, while the app under test uses the pooled string instead. This is the same rule as non-branching Neon seeding; [the Neon seeding guide covers the connection-string mechanics](/blog/seed-neon-database#connection-strings-pooled-unpooled-and-when-each-matters) in detail. **Auto-suspend mid-seed.** Neon computes suspend after an inactivity window; the default is 5 minutes on all plans, and the Free plan cannot disable it. A seed that pauses between large batches can have its connection closed underneath it, surfacing as `server closed the connection unexpectedly` or a generic TCP reset. Paid plans can raise the suspend delay for the seed branch; Free-plan users need the seed to run continuously without long inter-batch pauses. `seedfast seed` runs one continuous session, so this mainly bites hand-rolled scripts that batch-and-wait. **Stale parent.** The parent-seed pattern assumes the parent's data still reflects the current schema. When a migration lands on `main`, either (a) run the migration against the parent branch's compute and re-run the parent seed to regenerate rows that fit, or (b) accept that the parent will drift and rely on Pattern 2 (drift-reseed on every PR that touches migrations). Most teams pick (a) as a post-merge job on `main`. ## Neon branching seed data: three patterns, one binary For the practical setup, the article covers three workflows and picks between them based on the PR: | Scenario | Pattern | What happens in CI | | ---------------------------------------- | ------------------- | -------------------------------------------------------------------- | | PR touches app code only | Inherit from parent | Create branch, run migrations (no-op), run tests | | PR touches migrations | Drift reseed | Create branch, run migrations, run Seedfast on the branch, run tests | | Branch is schema-only (compliance scope) | Schema-only rescue | Create schema-only branch, run migrations, run Seedfast, run tests | All three use the same `seedfast` binary with different scope strings; the difference is when you invoke it, not how. That's the operational simplification that makes Neon branching seed data manageable at team scale, one binary and one mental model standing in for what used to be three separate tools. ## Frequently asked questions ### Do Neon branches inherit seed data from the parent? Yes, by default. A full branch copies both schema and data from the parent through copy-on-write, so the preview branch is populated the instant it's created, with no separate seeding step required. Schema-only branches are the exception, since they copy the table structure but skip the data, so those branches come up empty and need seeding on their own. ### Should I seed every Neon branch or just the parent? Seed the parent. Preview branches inherit the data, which keeps CI time and Neon compute cost flat regardless of how many PRs you have open. Reseed a branch only when something has changed that the inherited data can't represent, typically a new migration introducing columns, tables, or FK changes. Seedfast reads the schema of whichever branch you aim it at, so the reseed lands against the migration that triggered it rather than against the parent's older shape. ### How do I populate a schema-only Neon branch? Run the seeder against the branch's direct connection string after migrations. Static `seed.sql` breaks fast here, since the file rarely stays current with the schema a schema-only branch actually has. Seedfast generates rows for whichever tables and columns the branch actually has, so `seedfast seed --scope "..."` works exactly the same way it does on a full branch; it just has more empty tables to fill. ### Can I reset a Neon branch back to parent state mid-test? Yes. The Neon API's restore endpoint (`POST /api/v2/projects/{project_id}/branches/{branch_id}/restore` with a `source_branch_id` body) drops divergent writes and restores the parent's state in about a second. The tight feedback loop is useful when debugging flaky tests or accidentally polluting a preview branch. ### How do I seed a Neon preview branch in GitHub Actions? If the parent is already seeded, you don't, because the branch inherits the data when `neondatabase/create-branch-action` runs. If the PR changes the schema, detect the migration change (e.g., with `tj-actions/changed-files`) and run `seedfast seed` only on those branches. Use the unpooled (`db_url`) output from the action for the seed step instead of the pooled URL. ### What happens if I hit Neon's branch limit? Branch creation in CI fails. Pair `create-branch-action` on `pull_request: opened` with `delete-branch-action` on `pull_request: closed` in the same workflow repo. For extra safety, add a scheduled cleanup that deletes branches older than N days whose PR is closed, since stale preview branches are the usual culprit. ### Does this pattern work with Prisma / Drizzle / Kysely migrations? Yes. The create-branch → run-migrations → (optional seed) → run-tests pipeline is ORM-agnostic. Prisma uses `prisma migrate deploy`, Drizzle uses `drizzle-kit migrate` to apply committed migration files (use `drizzle-kit push` only for local schema prototyping, never in CI), and Kysely uses whatever migrator you've wired up. `seedfast` talks to Postgres directly and doesn't care which ORM manages the schema. ### Can I keep my existing seed.ts for fixture records and use Seedfast for bulk data? Yes, and most teams do. Seedfast handles the bulk relational data (orders, events, activity history) that makes tests realistic but that nobody wants to hand-write, while the handful of records a test asserts on by exact value stay in your existing `seed.ts` or a short `fixtures.sql`. Run Seedfast first for volume, then the fixture file for specifics; the two share the same Postgres connection and never step on each other. ### Is the data Seedfast generates reproducible across runs? Not by default, since the generator produces fresh values on each run, which is what you want when the goal is a realistic dataset. Tests that depend on specific literal values (an email, a product name) should stay in a fixture file that inserts those rows deterministically. For E2E baselines that need the exact same dataset every run, the standard pattern is to run Seedfast once, `pg_dump` the result, and restore the dump in CI, the same approach any generator-based workflow uses. ## Related guides - [How to seed a Neon database](/blog/seed-neon-database), covering the three seeding methods (raw SQL, ORM, schema-aware) this article builds on, including connection-string gotchas and per-error fixes - [How to seed a Supabase database](/blog/supabase-db-seed), the Supabase counterpart covering `seed.sql`, `supabase db reset`, `auth.users`, and Supabase preview branches (separate-DB-per-branch model, not CoW) - [Seed file maintenance](/blog/seed-file-maintenance), on why static `seed.sql` and hand-written `seed.ts` drift from the schema, and how that drift shows up faster on Neon preview branches - [Database seeding in CI/CD](/blog/database-seeding), the framework for idempotent, fast, FK-valid seeding in a pipeline, applied to any Postgres (Neon-specific here; this covers the general case) - [Database seeder tools and per-ORM reference](/blog/database-seeder), comparing Laravel, Prisma, Drizzle, EF Core, and TypeORM seeders side-by-side - [Get started with Seedfast](/docs/seed-your-database), to connect the CLI to your Neon project and run the three patterns in under five minutes --- # Supabase DB Seed: seed.sql, config.toml & auth.users > Source: https://seedfa.st/blog/supabase-db-seed > Last updated: 2026-09-10 **Seeding a Supabase database means loading a starting set of rows into Postgres so that local development, automated tests and preview branches each open on a populated schema rather than an empty one.** On Supabase that work happens through the CLI, where `supabase db reset` and `supabase start` execute the SQL files declared in the [`[db.seed]` block](https://supabase.com/docs/guides/local-development/seeding-your-database) of `supabase/config.toml`. No standalone `supabase db seed` subcommand exists. The default file is `supabase/seed.sql`, and `sql_paths` accepts globs once a single file stops being readable. This Supabase seed database guide covers the whole seeding surface (`config.toml`, `seed.sql`, `auth.users`, preview branches) before ending on [Seedfast](https://seedfa.st/), which reads your live Supabase schema and generates realistic, FK-valid relational data from a plain-English scope. Production data never enters the run, and what you keep afterwards is the scope sentence rather than a seed script that has to track the schema. ## Key Takeaways - `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 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. From here, the guide walks through 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](/blog/seed-database) covers the PostgreSQL version without the Supabase-specific surface. This article is specifically about the Supabase CLI, `seed.sql`, branching, and `auth.users`. ## How to run supabase db seed (config.toml and seed.sql) 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 start` seeds on the first start of a local stack - `supabase db reset` drops 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. ## How a Supabase seed database actually works A new Supabase project gets this in `supabase/config.toml` when you run `supabase init`: ```toml [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: ```toml [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). ### Minimal config.toml for local development with seed.sql **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. ```toml # 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. ### Disable seeding (db.seed.enabled = false) **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. ```toml [db.seed] enabled = false sql_paths = ['./seed.sql'] ``` The common reasons to flip it off are 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), leaving `seed.sql` 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. ### Order: migrations run before seeds **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. ## Method 1: Seeding with `seed.sql` (the default path) The simplest seed is one SQL file with INSERTs. ```sql -- 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: ```bash 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. Running as the `postgres` superuser locally, the seed file bypasses RLS, which is the right default. The gotcha shows up in tests, where 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; the policies that let the right role read them back are 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](/blog/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. ## Method 2: TypeScript seeders against the Supabase database 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. ```typescript // 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](/blog/seed-database) has the ORM-by-ORM walkthrough. ### Connection strings on Supabase: session vs transaction pooler 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 Whichever of the three you take, a password carrying an `@` or a `#` has to be percent-encoded before it survives the URI, and the [connection string builder](/tools/postgres-connection-string-builder) does that for you. 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. A seed script, with its prepared statements and longer-lived session, is the workload that trips it. The same pooler keeps whatever a client `SET` on its backend and hands it to the next client, and [the PgBouncer transaction mode matrix](/blog/pgbouncer-transaction-mode) shows which state that includes. ## Method 3: Snaplet / supabase-community/seed 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: ```typescript 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](/compare/snaplet-seed-alternative), that guide walks through the move in detail; [the full category comparison](/blog/data-seeding-tools) 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](/compare/neosync-alternative) 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](/pricing) from the start, rather than a free-tier-only OSS project hoping for an acquisition or VC round. A paid product can still fail. The incentive is what changes, because keeping the product alive is the business itself rather than a side project waiting on an acquisition. ## Method 4: Seedfast (schema-aware seeding on live Supabase) [Seedfast](https://seedfa.st/) 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; the schema itself dictates FK order, and no seed plan exists for anyone to hand-edit after a migration. ```bash npm install -g seedfast # or: brew install seedfast-ai/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 circular FK chains, including cycles that are `NOT NULL` on both sides, 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: ```bash # 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. For the pipeline-side wiring (scope strings in GitHub Actions, `--output json` for programmatic checks, API keys), the [CI/CD database seeding guide](/docs/cicd-database-seeding) has the details, and it applies to Supabase projects unchanged. ### Seedfast with `auth.users` 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). In practice that means a two-step seed: ```bash # 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. The free plan covers the full first run end-to-end. Small schemas typically fit inside its monthly credits, and no credit card is required to start. [Connect the CLI to your Supabase project](/docs/seed-your-database); for what crosses the wire during a run, see [data handling and privacy](/privacy-policy). ## Seeding `auth.users` without breaking your login flow 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. ```typescript // 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. ```sql -- 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, and remembering it is on you whenever you insert by hand. 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](/docs/seed-your-database) to fill the application tables that reference those users. ### Insert into auth.users in seed.sql (local development) **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. ```sql -- 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_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; pass `gen_salt('bf', 10)` if you want production-grade cost. If you would rather paste a finished hash than call `crypt()` inside the insert, the [PostgreSQL password hash generator](/tools/scram-sha-256-generator) produces one in the browser with the `$2a$` prefix `crypt()` reads back. - `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). ### Fix "converting NULL to string is unsupported" with confirmation_token **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`](https://github.com/supabase/auth/blob/master/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. ```sql -- 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). Because the admin API (`supabase.auth.admin.createUser()`) sets these columns itself, the failure mode is specific to raw SQL inserts. ### auth.identities provider_id is NOT NULL (since November 2023) **Since GoTrue migration [`20231117164230_add_id_pkey_identities`](https://github.com/supabase/auth/blob/master/migrations/20231117164230_add_id_pkey_identities.up.sql) 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 (`::text`). Skipping it returns `null value in column "provider_id" of relation "identities" violates not-null constraint`.** ```sql 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, and that is the reason the admin API path survives GoTrue version changes that force an update to every hand-written insert. ## Seeding a remote Supabase project 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. ```bash 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](https://github.com/supabase/supabase/discussions), but the feature is not in the CLI as of June 2026. For Seedfast the local/remote distinction never existed — `seedfast seed` against the direct connection string behaves identically in both, which is why teams seeding multiple Supabase environments on the same day standardize on it for remote work. ## Supabase branching and seed data Supabase branches are full Postgres databases provisioned per Git branch. On creation, the preview branch re-applies your migrations and your `seed.sql`, and a well-maintained file therefore hands every new 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. Each new branch gets its rows regenerated from its own live schema on every run, where a TypeScript seeder in CI would need its code hand-maintained through every migration. For the branch-level mechanics (copy-on-write, seed-parent-once, reseed-on-drift), [Neon branching seed data](/blog/neon-branching-seed-data) covers the patterns in Neon-specific detail; [how to seed a Neon database](/blog/seed-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. ## Seedfast as the test data generator for a Supabase database branch **Seedfast reads a preview branch's live schema on every run and writes connected rows that satisfy it, including the `auth.users` references your `public.*` tables carry, so a branch whose relational bulk has outgrown `seed.sql` gets data regenerated for the schema it actually has rather than a replay of a static file.** Literal reference rows that a test asserts on by value still belong in `seed.sql`. Everything around them, the scenario data that has to move whenever the schema does, comes out of the run. 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. Seedfast carries no such file, because it asks the branch's database for the current schema at the start of every run, which is how a column that landed on `main` this morning ends up in tonight's seed with nothing to hand-edit. Point [Seedfast](https://seedfa.st/) at the branch's direct or session-pooler connection string and scope the run to what the PR actually exercises: ```bash # 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`; Seedfast writes 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](/blog/seed-neon-database); for the broader Postgres tool decision, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) guide compares the options, and [Seedfast pricing](/pricing) is a flat monthly plan that includes a pool of credits each run draws from. ## Common Supabase seeding errors and how to fix them ### `prepared statement "s1" already exists` You are seeding through the transaction pooler (port `6543`). Switch the connection string to the direct (`db..supabase.co:5432`) or session-pooler port (`5432` on the pooler host). ### `permission denied for schema auth` 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. ### `duplicate key value violates unique constraint "users_email_key"` on re-seed 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. ### `new row violates row-level security policy` when the app reads seeded data 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. ### `relation "auth.users" does not exist` in a migration 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. ### `cached plan must not change result type` 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. ## `seed.sql` vs TypeScript vs Snaplet vs Seedfast on Supabase | 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) | Free plan, paid plans after | | Sends schema off-machine | No | No | No | Yes; schema metadata goes to the Seedfast service for planning ([what crosses the wire](/privacy-policy)) | | 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 | Read the table by job rather than by column. Fixture rows suit `seed.sql`, and authenticated users go through the admin API; the relational bulk that outgrows both is what Seedfast handles without a seed file to keep in sync. [Try Seedfast on your Supabase schema](/docs/seed-your-database), free to start with no credit card. ## Frequently asked questions ### 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? 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 --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: ```toml [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`? 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 free plan needs no credit card and never expires, and most small schemas fit inside its monthly credits without hitting a wall. Point `seedfast connect` at your Supabase direct or session-pooler connection string; if a run would draw more credits than the plan holds, the CLI says so and links to [the Seedfast pricing page](/pricing). ### 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 = ::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`](https://github.com/supabase/auth/blob/master/migrations/20231117164230_add_id_pkey_identities.up.sql) (November 2023), `auth.identities.provider_id` is `NOT NULL`. For an email identity, set `provider_id` to the user's UUID cast to text (`::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. Seedfast regenerates against that branch's current schema on every run, keeps its foreign key references resolving, and reuses any `auth.users` rows already there as parents, so a migration shipped in the same PR lands in the data without a seed file edit. Literal fixtures stay in `seed.sql`, and the connected bulk comes from the run. ## Try Seedfast on your Supabase schema If the part that hurts is `seed.sql` breaking every migration, start here: ```bash 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 follows, and no `seed.ts` needs regenerating, because the schema itself stays the single source of truth for every subsequent run. [Get started with Seedfast](/docs/seed-your-database), where the free plan runs without a credit card. ## Related guides - [Database seeding methods and best practices](/blog/database-seeding): the conceptual pillar covering reference vs test data, idempotency, and when seed files stop scaling - [Database seeder tools compared](/blog/database-seeder): quick reference for picking a seeder, with per-ORM command syntax - [How to seed a database: PostgreSQL practical guide](/blog/seed-database): the framework-agnostic version with Prisma, Drizzle, TypeORM, and raw `pg` - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql): the raw SQL primitives (`generate_series`, `psql \copy`, `pg_dump` carving) for filling tables before you reach for a generator - [How to seed a Neon database](/blog/seed-neon-database): the Neon-specific sibling to this article, covering pooled vs direct, branch inheritance, and the three seeding methods - [PgBouncer transaction mode: what the next client inherits](/blog/pgbouncer-transaction-mode): the measured matrix for port 6543, which state a client leaves on the backend for the next one and which the pooler puts back - [Neon branching seed data](/blog/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](/blog/seed-file-maintenance): why static `seed.sql` drifts from the schema and what the alternatives look like - [Test data management](/blog/test-data-management): the strategy layer over all of this — provisioning, refreshing, and governing data across local, CI, and staging - [Data seeding tools compared](/blog/data-seeding-tools): the full comparison of Faker, ORM seeders, enterprise anonymization, and schema-aware generators - [Database seeding in CI/CD](/docs/cicd-database-seeding): pipeline wiring for Supabase, Neon, and any Postgres - [Get started with Seedfast](/docs/seed-your-database): connect to your Supabase project and run the first schema-aware seed --- # How to Seed a Neon Database: psql, Prisma, Drizzle > Source: https://seedfa.st/blog/seed-neon-database > Last updated: 2026-07-22 **To seed a Neon database, use the unpooled connection string with `psql`, Prisma, or Drizzle. The pooled URL breaks prepared statements mid-seed.** Neon gives you an empty serverless Postgres in under a second; then you stare at it. This guide covers three ways to seed Neon database tables (`psql`, an ORM seed, and Seedfast, which reads your live Neon schema and generates FK-valid relational data from a plain-English scope with no seed scripts to maintain) plus how branching changes the seeding model. ## Key Takeaways - If your seed throws `prepared statement "s1" already exists` or `cached plan must not change result type`, you're connecting through the pooled URL. Switch to the **unpooled** string (no `-pooler` in the hostname). PgBouncer transaction mode breaks prepared statements and times out long-running scripts - Neon's main branch starts empty. Every new project, every new branch, and every preview environment needs test data before the app is usable - Prisma and Drizzle both seed Neon fine, but Prisma on edge runtimes requires the `@prisma/adapter-neon` adapter plus `ws`; the standard `pg` driver won't work there - Neon branching inherits data from the parent branch, so seeding once at the parent lets preview branches start with that dataset, ready in about a second - When the schema changes, and it will, static seed files break. [Seedfast](https://seedfa.st/) notices the change on its next run and regenerates valid data, so there is no seed file to maintain **Quick fix if you landed here from a broken seed:** ```bash # Pooled URL — breaks seeding (PgBouncer transaction mode) postgresql://user:pass@ep-xxxx-pooler.region.aws.neon.tech/dbname?sslmode=require # Unpooled URL — use this for seeds, migrations, admin scripts postgresql://user:pass@ep-xxxx.region.aws.neon.tech/dbname?sslmode=require ``` You provisioned Neon because it [spins up in milliseconds](https://neon.com/docs/introduction/serverless), scales to zero, and lets you branch databases per pull request. But your main branch is empty, and your CI branches inherit that emptiness. To seed a Neon database, you need three things: the right connection string, a strategy that survives schema changes, and an understanding of how Neon branching changes the seeding model. We'll cover all three, starting with raw SQL, then Prisma and Drizzle seed scripts with Neon's serverless driver, then how [Seedfast](https://seedfa.st/) turns a plain-English scope into connected rows without a seed file to write. If you want the general PostgreSQL version first, [how to seed a database](/blog/seed-database) covers the cross-framework fundamentals; this article is specifically about Neon. ## When do you need to seed a Neon database? Neon's serverless Postgres starts life as an empty database. Unlike a shared dev server that accumulates data over months, a Neon project is fresh. Branches are fresh too. By default they copy from a parent, so if the parent is empty, the branch is empty. This matters more on Neon than on traditional Postgres hosting because the branching workflow puts a new database in front of you on every pull request. Three scenarios force the question of how to seed a Neon database: 1. **New project onboarding.** A developer clones the repo, creates a Neon project, and runs the migrations; the tables exist, but nothing else does. 2. **Preview branches per PR.** The GitHub Action creates a branch for every pull request, and if the parent is empty, every preview is empty too, which means your end-to-end tests hit 404s. 3. **Staging and demo environments.** You need 500 products and realistic order histories to show someone what the app looks like with data in it. [Neon's branching docs](https://neon.com/docs/guides/branching-intro) push branching over seeding, arguing for forking from a parent that already has data so every child branch inherits it automatically. That's elegant, but it leaves the parent-seeding problem unsolved. Someone still has to fill the parent branch the first time. ## Connection strings: pooled, unpooled, and when each matters Neon gives every branch two connection strings. You can copy them from the Neon dashboard under **Connection Details**: ```bash # Pooled (goes through PgBouncer, up to 10,000 concurrent connections) postgresql://user:pass@ep-xxxx-pooler.region.aws.neon.tech/dbname?sslmode=require # Unpooled / direct (straight to Postgres) postgresql://user:pass@ep-xxxx.region.aws.neon.tech/dbname?sslmode=require ``` Note the `-pooler` in the pooled hostname. That's [PgBouncer in transaction mode](https://neon.com/docs/connect/connection-pooling), which returns connections to the pool after every transaction. It's what you want for a serverless Next.js app handling thousands of short requests. It's **not** what you want for seeding, because: - Prepared statements don't survive the transaction boundary - Long-running `COPY` operations and large transactional seeds can hit statement timeouts - Some ORMs emit session-level `SET` statements that get discarded For seeding, migrations, and any admin script, use the **unpooled** string. Export it explicitly: ```bash # .env.local DATABASE_URL="postgresql://...ep-xxxx-pooler.../dbname?sslmode=require" # app runtime DIRECT_URL="postgresql://...ep-xxxx.../dbname?sslmode=require" # migrations + seeds ``` Prisma calls this `directUrl` in `schema.prisma`, while Drizzle doesn't care and just wants whichever URL matches your context. If your seed inserts 10,000 rows and fails halfway with "prepared statement already exists" or "cached plan must not change result type", you're seeding through the pooler, so switch to the unpooled URL. ## Method 1: Seed Neon database with raw SQL The simplest thing that works is writing INSERTs and running them with `psql`. ```sql -- seed.sql INSERT INTO teams (id, name) VALUES (1, 'Engineering'), (2, 'Design') ON CONFLICT (id) DO NOTHING; INSERT INTO users (id, email, team_id) VALUES (1, 'alice@example.com', 1), (2, 'bob@example.com', 2) ON CONFLICT (id) DO NOTHING; ``` ```bash psql "$DIRECT_URL" -f seed.sql ``` SSL is mandatory on Neon. The `sslmode=require` query parameter in the connection string handles it automatically. If you get `FATAL: connection requires SSL`, your URL is missing it. When you have tens of thousands of rows to load, `COPY FROM STDIN` is substantially faster than row-by-row INSERTs. The gap narrows when comparing against batched multi-row INSERTs, but COPY still avoids per-row parsing overhead: ```bash psql "$DIRECT_URL" -c "COPY products (name, price, category_id) FROM STDIN CSV" < products.csv ``` `ON CONFLICT DO NOTHING` keeps the seed idempotent so CI reruns don't fail on the second attempt. For data that should reflect the latest values, use `ON CONFLICT DO UPDATE`: ```sql INSERT INTO feature_flags (key, enabled) VALUES ('new_checkout', true) ON CONFLICT (key) DO UPDATE SET enabled = EXCLUDED.enabled; ``` Raw SQL is fine for reference data like roles, feature flags, and country codes. Past ten tables with foreign keys, though, it starts breaking, because every migration that adds a required column or a new FK reference forces you to hand-edit the seed file. [Seed file maintenance](/blog/seed-file-maintenance) covers why this lifecycle is so brutal on active codebases. ## Method 2: Seed Neon database with an ORM Most Neon projects run through Prisma, Drizzle, or Kysely. Each has its own seeding path. ### Prisma + Neon Prisma on a Node.js server works with Neon out of the box via the standard `pg` driver. Edge runtimes (Vercel Edge, Cloudflare Workers) require the Neon serverless driver adapter. In a regular Node.js seed script, just point Prisma at the unpooled URL: ```typescript // prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const team = await prisma.team.upsert({ where: { name: "Engineering" }, update: {}, create: { name: "Engineering" }, }); await prisma.user.upsert({ where: { email: "alice@example.com" }, update: {}, create: { email: "alice@example.com", teamId: team.id }, }); } main() .catch(console.error) .finally(() => prisma.$disconnect()); ``` ```typescript // prisma.config.ts import { defineConfig, env } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", seed: "tsx prisma/seed.ts", }, datasource: { url: env("DIRECT_URL") }, }); ``` ```bash npx prisma db seed ``` If you deploy on Vercel Edge or Cloudflare Workers, the runtime has no TCP sockets. Install the Prisma Neon adapter: ```bash npm install @prisma/adapter-neon @neondatabase/serverless ws ``` ```typescript import { PrismaClient } from "@prisma/client"; import { PrismaNeon } from "@prisma/adapter-neon"; import { Pool, neonConfig } from "@neondatabase/serverless"; import ws from "ws"; neonConfig.webSocketConstructor = ws; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaNeon(pool); const prisma = new PrismaClient({ adapter }); ``` Seeding itself rarely runs on edge. It runs in CI or locally, so the plain Node.js setup with the unpooled URL is what most teams use for the seed script. ### Drizzle + Neon Drizzle ships two Neon-specific packages: `drizzle-orm/neon-http` for one-shot HTTP queries, which suits serverless app code but **not** seed transactions, and `drizzle-orm/neon-serverless` for WebSocket sessions with full transaction support, the one you want for seeds. The simplest seed path is the node-postgres driver against the unpooled URL: ```typescript // scripts/seed.ts import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { teams, users } from "../src/db/schema"; const pool = new Pool({ connectionString: process.env.DIRECT_URL }); const db = drizzle(pool); async function seed() { const [team] = await db .insert(teams) .values({ name: "Engineering" }) .onConflictDoUpdate({ target: teams.name, set: { name: "Engineering" } }) .returning(); await db .insert(users) .values({ email: "alice@example.com", teamId: team.id }) .onConflictDoNothing(); await pool.end(); } seed().catch((e) => { console.error(e); process.exit(1); }); ``` ```bash tsx scripts/seed.ts ``` If you prefer the Neon serverless driver for consistency with the rest of your codebase: ```typescript import { drizzle } from "drizzle-orm/neon-serverless"; import { Pool, neonConfig } from "@neondatabase/serverless"; import ws from "ws"; neonConfig.webSocketConstructor = ws; const pool = new Pool({ connectionString: process.env.DIRECT_URL }); const db = drizzle(pool); ``` Either works, since the `pg` version is one less dependency while the serverless-driver version keeps your app and seed script on the same driver. Both Prisma and Drizzle share the same ORM seed problem, since the values are hand-written. When a migration adds a `NOT NULL organization_id` column, your seed breaks on the next run, and someone (usually the person on call) has to fix it before anyone on the team can run the app. Seedfast removes that break-on-migration step entirely, picking up the new column automatically and filling it without a seed-file edit. ## Using @neondatabase/serverless with local Postgres for development **[`@neondatabase/serverless`](https://neon.com/docs/serverless/serverless-driver) cannot talk to a regular local Postgres on `localhost:5432`. The HTTP driver (`neon()`) speaks HTTP to Neon's proxy, and the WebSocket driver (`Pool` from `@neondatabase/serverless`) speaks WebSocket to Neon's serverless gateway. Because plain Postgres only understands the binary wire protocol on TCP, the connection fails before any query runs.** This is the standard combination for apps deployed on Vercel Edge Functions or Cloudflare Workers against Neon, where production runs on the edge with the serverless driver while local development wants plain Postgres in Docker. There are two ways out. Swap the Drizzle driver per environment (recommended), or run a local WebSocket proxy that translates for `@neondatabase/serverless`. ### Solution 1: swap drivers per environment (drizzle-orm/node-postgres locally, neon-http in production) **Keep `drizzle-orm/neon-http` (or `drizzle-orm/neon-serverless`) for production and use `drizzle-orm/node-postgres` against local Postgres. Drizzle's schema, types, and query API are identical across drivers, so only the file that constructs `db` changes.** ```typescript // src/db/index.ts import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"; import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; import { neon } from "@neondatabase/serverless"; import { Pool } from "pg"; import * as schema from "./schema"; const connectionString = process.env.DATABASE_URL!; export const db = process.env.NODE_ENV === "production" ? drizzleNeon(neon(connectionString), { schema }) : drizzlePg(new Pool({ connectionString }), { schema }); ``` Local `.env`: ```bash DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" ``` Production `.env`: ```bash DATABASE_URL="postgresql://user:pass@ep-xxxx.region.aws.neon.tech/dbname?sslmode=require" ``` Your schema file and every query (`db.select().from(users)…`) stays the same. The only divergence is the driver factory. If you need transactions locally, `node-postgres` already supports them. `neon-http` does not, so any code that uses `db.transaction(...)` either has to live behind a server action that runs on Node, or you switch the production driver to `drizzle-orm/neon-serverless` (WebSocket, transactions supported on the edge). It comes down to one process-env flip and two factories, and the query code stays identical either way. ### Solution 2: local WebSocket proxy for @neondatabase/serverless **If you need a single code path that uses `@neondatabase/serverless` everywhere, run a local WebSocket-to-Postgres proxy. Neon's [`wsproxy`](https://github.com/neondatabase/wsproxy) accepts WebSocket connections on a local port and forwards them to a real Postgres instance, so the serverless driver thinks it's talking to Neon.** Minimal `docker-compose.yml`: ```yaml services: postgres: image: postgres:16 environment: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] wsproxy: image: ghcr.io/neondatabase/wsproxy:latest environment: APPEND_PORT: "postgres:5432" ALLOW_ADDR_REGEX: ".*" ports: ["4444:80"] depends_on: [postgres] ``` Point `@neondatabase/serverless` at the local proxy: ```typescript import { Pool, neonConfig } from "@neondatabase/serverless"; import ws from "ws"; neonConfig.webSocketConstructor = ws; neonConfig.wsProxy = (host) => `localhost:4444/v2`; neonConfig.useSecureWebSocket = false; neonConfig.pipelineTLS = false; neonConfig.pipelineConnect = false; const pool = new Pool({ connectionString: "postgresql://postgres:postgres@localhost:5432/postgres", }); ``` The trade-off is an extra container, an extra protocol to debug when something breaks, and a second handshake on every connection. Solution 1 needs one env check; Solution 2 needs a running container and a WebSocket connection to inspect. Reach for the proxy only if you need exact edge parity in local dev, for example hunting a bug that only reproduces over WebSocket. ### drizzle-kit push local postgres "neon serverless" websocket warning **`drizzle-kit push` and `drizzle-kit migrate` always use `node-postgres` under the hood. The warning means your `drizzle.config.ts` is pointing at a Neon serverless or pooled URL, and drizzle-kit is telling you it will ignore the serverless bits and connect over plain TCP. Giving it a `pg`-compatible URL fixes it, Neon's unpooled direct connection for production and plain local Postgres for development.** ```typescript // drizzle.config.ts import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED || "postgresql://postgres:postgres@localhost:5432/postgres", }, }); ``` For Neon, `DATABASE_URL_UNPOOLED` is the hostname without `-pooler` and with `?sslmode=require`. Locally, plain `postgresql://postgres:postgres@localhost:5432/postgres` is enough. Do not try to silence the warning by setting `driver: 'pg'` plus a serverless URL. The underlying mismatch is the connection string, not the config key. (Drizzle-kit removed the `driver` field altogether in 0.21+, making the workaround a no-op on newer versions.) ### drizzle-orm/neon-http vs drizzle-orm/neon-serverless vs drizzle-orm/node-postgres **Three drivers speak three different wire protocols behind one Drizzle query API.** | Driver | Wire protocol | Transactions | Runtime | Works against | | ----------------------------- | ------------------- | ------------ | ----------- | ------------------------------ | | `drizzle-orm/neon-http` | HTTPS to Neon proxy | No | Node + edge | Neon only | | `drizzle-orm/neon-serverless` | WebSocket to Neon | Yes | Node + edge | Neon only | | `drizzle-orm/node-postgres` | TCP wire protocol | Yes | Node only | Any Postgres (including local) | Pick by deployment target: - **Edge with one-shot queries, no transactions:** use `neon-http` for the lowest latency on a single read. - **Edge with transactions:** use `neon-serverless` to keep a WebSocket session open, matching the edge-runtime story. - **Local development, CI Postgres in Docker, or any non-Neon host:** use `node-postgres`, since the serverless drivers won't connect here. The conditional-import pattern from Solution 1 above lets you mix and match: `node-postgres` locally for `pnpm dev`, `neon-http` or `neon-serverless` for the deployed app. ## Method 3: Schema-aware seeding with Seedfast Seedfast connects to your Neon branch, reads the live schema (tables, columns, constraints, foreign keys) and generates a valid, connected dataset. You describe what the data should look like in plain English. There's no `seed.sql` or `seed.ts` to maintain. ```bash npm install -g seedfast # or: brew install seedfast-ai/tap/seedfast # Log in and connect to your Neon database seedfast connect # Paste the Neon unpooled connection string when prompted # Generate data from the current schema seedfast seed --scope "small engineering team with 3 projects and task assignments" ``` When a migration adds a new table or column, the next `seedfast seed` picks it up automatically, with no file to update and no foreign key order to work out by hand. 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. Different scopes for different environments: ```bash # Local dev — minimal, fast seedfast seed --scope "2 teams, 5 users, 10 products" # Preview branch for a feature PR seedfast seed --scope "3 users with completed onboarding, 5 draft posts, 2 published" # Staging demo seedfast seed --scope "500 realistic products, 50 users with 6 months of order history" ``` Seedfast works alongside Prisma, Drizzle, Kysely, and plain `pg` because it talks to PostgreSQL directly over the wire. Run your migrations first with whichever tool you prefer, then run Seedfast to fill the tables. For production reference data (feature flags, country codes, admin roles) you still want a versioned SQL file or ORM seed, because that data belongs to the application, not a test dataset. Seedfast is built for development, CI, staging, and demo data; the handful of rows that actually ship to production still belongs in that versioned file. Generated rows land straight in your Neon branch, with no CSV to import, no intermediate file to load, and no manual `psql` step at the end. Run the seed command once and the tables are full. See [data handling and privacy](/privacy-policy) for exactly what crosses the wire. A free plan is available. [Connect and run your first seed](/docs/seed-your-database) in about two minutes. ## Generate test data for a Neon branch (that survives a branch reset) **After a branch reset, the thing that has to survive is the generator, not a `git`-tracked seed file.** Seedfast regenerates from the branch's current schema every time it runs, so repopulating after a reset means issuing the command again instead of restoring a file. That is what a test data generator for Neon has to do once branch resets are part of the daily loop. Neon lets you [instantly reset a branch to its parent](https://neon.com/docs/manage/branches), which wipes every branch-local write. After a reset, you have to repopulate. With a static `seed.sql` that's a `psql` run; with a hand-edited ORM seed it's a `psql` run plus whatever migrations have landed since you last touched the file. Two things make a Neon branch different from a plain database, and both favor reading the schema over replaying a file: A branch created off `main` inherits `main`'s schema at fork time, but if a migration lands on the branch in the same PR, the branch's tables no longer match the seed file written against `main`. Regenerating against the branch's own schema sidesteps that, since Seedfast targets the branch's real tables, columns, and FKs, not the ones from yesterday. And because a reset throws away branch-local data, the cheap loop becomes resetting the branch and rerunning the generator, instead of resetting the branch and then reviewing a diff on the seed file. Re-run the same scope against the branch's unpooled URL (the install and `seedfast connect` steps are in [Method 3](#method-3-schema-aware-seeding-with-seedfast) above): ```bash # After resetting the branch — repopulate from its current schema seedfast seed --scope "3 users with completed onboarding, 5 draft posts, 2 published" ``` Keeping `neon branch seed data` current becomes a property of the run instead of something stored in a file you maintain. Static `psql`/ORM seeds are still the right call for versioned reference rows like feature flags and country codes, because those belong in git and should survive a reset by being committed. For the deeper per-PR branching workflow (seed-parent-once, drift-reseed, schema-only rescue), see [Neon branch seeding](/blog/neon-branching-seed-data); for the broader tool decision across Postgres hosts, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison weighs the options. ## Seed Neon once, branch many times Neon branching turns one seeded parent into many populated branches, since you seed the parent once and every branch forked from it inherits that dataset in milliseconds. Keeping that parent dataset current across schema changes is where Seedfast fits. Every run starts from the parent's schema as the migrations have left it, so parent data and schema never drift apart. A typical workflow looks like: ```bash # One-time setup on main branch psql "$DIRECT_URL" -f seed.sql # or seedfast seed --scope "realistic e-commerce dataset" ``` Then in CI, every pull request gets its own branch: ```yaml # .github/workflows/preview.yml name: Preview branch on: pull_request: jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Create Neon branch from main id: neon uses: neondatabase/create-branch-action@v6 with: project_id: ${{ secrets.NEON_PROJECT_ID }} branch_name: preview/pr-${{ github.event.pull_request.number }} parent_branch: main api_key: ${{ secrets.NEON_API_KEY }} - name: Run migrations on the new branch run: npx prisma migrate deploy env: DIRECT_URL: ${{ steps.neon.outputs.db_url }} - name: Run E2E tests run: npm run test:e2e env: DATABASE_URL: ${{ steps.neon.outputs.db_url_pooled }} ``` There's no seeding step in CI, because the branch already has data, inherited from `main`. Branch creation takes about a second, so the PR pipeline isn't waiting on database provisioning. When the PR is closed or merged, a cleanup action deletes the branch: ```yaml # .github/workflows/cleanup.yml on: pull_request: types: [closed] jobs: delete-branch: runs-on: ubuntu-latest steps: - uses: neondatabase/delete-branch-action@v3 with: project_id: ${{ secrets.NEON_PROJECT_ID }} branch: preview/pr-${{ github.event.pull_request.number }} api_key: ${{ secrets.NEON_API_KEY }} ``` The two pieces fit together when you seed the parent with a realistic dataset, then branch per PR for isolated preview environments. The only thing you have to get right is keeping the parent's seed data current, and that's exactly what Seedfast does, rebuilding the parent dataset each time from the schema the migrations have already produced. For the general CI case without branching, the approach in [CI/CD database seeding](/docs/cicd-database-seeding) applies to Neon too. ## Common Neon seeding issues and how to fix them ### "prepared statement `s1` already exists" You're seeding through the pooled (`-pooler`) connection string. PgBouncer transaction mode discards prepared statements between transactions, and the driver tries to reuse a statement that's gone. Switch to the unpooled URL for the seed script. ### "cached plan must not change result type" This is a server-side Postgres plan-cache error, where the database cached the execution plan for a prepared statement, but a subsequent schema change (column type, table restructure) invalidated it. Run migrations before the seed, and make sure you are using the unpooled URL. PgBouncer can mask this error by discarding statement state between transactions rather than surfacing it cleanly. ### "connection terminated unexpectedly" mid-seed Neon compute auto-suspends branches that have been idle (default is 5 minutes on all plans, including paid). If your seed script pauses between large batches, the connection may close before you resume. For long seeds, keep the script running continuously or increase the compute's suspend delay in the Neon dashboard. ### "SSL required" Your connection string is missing `?sslmode=require`, and Neon rejects non-SSL connections outright. Add the query parameter or set `PGSSLMODE=require` in the environment. ### "permission denied for schema public" Neon projects have one default role, the owner role (named for your database, e.g., `neondb_owner`), which has full access. If you are using a role you created separately with limited grants, your seed INSERTs will fail. Use the project owner role for seeding. ### "too many connections" You've opened more connections than the compute allows. The limit scales with compute size (a 0.25 CU Neon compute supports around 104 total connections; a 1 CU supports 419, per [Neon's compute docs](https://neon.com/docs/manage/computes)). Close pools after seeding (`pool.end()` for `pg` / Drizzle, `prisma.$disconnect()` for Prisma). For parallel seed scripts, serialize them or lower the pool size (`max: 5` in `new Pool(...)`), a number the [connection pool size calculator](/tools/postgres-connection-pool-calculator) works out from your compute size and how many workers run at once. Use the direct URL for the seed process only. ### "relation does not exist" Run migrations before seeding. Neon branches copy data from the parent, but if the parent hasn't had migrations applied, the schema is stale. The order is always `migrate → seed`; it never runs in reverse. ## Manual SQL vs ORM vs Seedfast for Neon | Aspect | Raw SQL (`psql -f`) | Prisma / Drizzle seed | Seedfast | | ------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------------- | ------------------------------------------------- | | Setup time | None | Already there if you use the ORM | `npm install -g seedfast` | | External dependency | None — `psql` is everywhere | None — already in your stack | Yes — separate CLI plus a network call to your DB | | File you maintain | `seed.sql` | `seed.ts` | None — reads the live schema | | FK order | Manual | Manual | Automatic | | Survives migrations | No — requires manual updates on schema changes | No — requires manual updates on schema changes | Yes — regenerates from the live schema | | Realistic volumes | Painful beyond ~50 rows | Works with Faker, still manual | Natural-language scope describes the dataset | | Works with Neon branches | Yes (unpooled URL) | Yes (unpooled URL) | Yes (unpooled URL) | | Good for static config (feature flags, country codes, roles) | Excellent — versioned and reviewed | Excellent — versioned and reviewed | Not the target | | Good for dev / CI / staging datasets | Manual re-sync on every migration | Manual re-sync on every migration | Regenerates from the live schema | Pick based on the job. Ship reference data as committed SQL or ORM seed. Use Seedfast for the large, evolving test datasets that are the actual source of seed-file pain. [Try it free on your Neon schema](/docs/seed-your-database), which takes about two minutes and never asks for a credit card. ## Frequently asked questions ### How do I seed a Neon database from the command line? Copy the unpooled connection string from the Neon dashboard (the hostname without `-pooler`) and run `psql "$DATABASE_URL" -f seed.sql`. Include `?sslmode=require` on the connection string. For Prisma projects, `npx prisma db seed` runs your `prisma/seed.ts`. For schemas with many tables or foreign keys, `seedfast seed --scope "..."` generates connected data without a seed file. ### Should I use the pooled or unpooled Neon URL for seeding? Use the **unpooled** URL for seeding, migrations, and admin scripts. The pooled URL routes through PgBouncer in transaction mode, which breaks prepared statements and can time out on large transactions. Use the pooled URL for your application at runtime, where short-lived transactions benefit from the pool. ### Do Neon branches inherit seed data from the parent? Yes by default. When you create a branch, Neon copies both the schema and the data from the parent branch. That means you can seed your `main` branch once and every preview branch forked from it starts with that dataset. Neon also supports schema-only branching if you want the structure without the data. ### How do I seed a Neon database in GitHub Actions? Create the branch with `neondatabase/create-branch-action`, run migrations against the branch's direct URL, then run your seed script. If the parent branch is already seeded, you can skip the seeding step, since the branch inherits the data. Use the pooled URL for application queries in your tests and the direct URL for migrations and seeds. ### How do I seed a Neon database with Prisma? Set `directUrl` in `schema.prisma` to Neon's unpooled connection string, keep `url` pointing at the pooled one for app queries, write your `prisma/seed.ts`, and run `npx prisma db seed`. If you deploy on an edge runtime, also install `@prisma/adapter-neon`, `@neondatabase/serverless`, and `ws`. But seeding itself usually runs in Node.js, not edge, so the adapter isn't required for the seed script. ### How do I seed a Neon database with Drizzle? Use `drizzle-orm/node-postgres` with the `pg` driver and Neon's unpooled URL for the seed script. For serverless/edge app code, switch to `drizzle-orm/neon-http` (one-off queries) or `drizzle-orm/neon-serverless` (transactions over WebSocket). Seedfast can also seed Drizzle-managed schemas directly, since it talks to Postgres instead of going through the ORM. ### Can I use @neondatabase/serverless with local Postgres for development? No. The `@neondatabase/serverless` package speaks HTTP (`neon()`) or WebSocket (`Pool`) to Neon's gateway, not the regular Postgres wire protocol. A plain Postgres at `localhost:5432` will not accept either connection. Use `drizzle-orm/node-postgres` with the `pg` driver against local Postgres and keep `drizzle-orm/neon-http` or `drizzle-orm/neon-serverless` for production. If you need a single code path, run Neon's `wsproxy` in Docker so `@neondatabase/serverless` can reach local Postgres over WebSocket. ### What connection string does @neondatabase/serverless use for local Postgres? There is no working local connection string for `@neondatabase/serverless` unless you also run a WebSocket proxy in front of Postgres. For local development, switch to `drizzle-orm/node-postgres` and use `postgresql://postgres:postgres@localhost:5432/postgres`. Production keeps Neon's serverless string: `postgresql://user:pass@ep-xxxx.region.aws.neon.tech/dbname?sslmode=require`. ### What's the best @neondatabase/serverless alternative driver for local Drizzle development? `drizzle-orm/node-postgres` with the `pg` package. The Drizzle schema, types, and query code are identical to `drizzle-orm/neon-http`, so the only file that changes is the one constructing `db`. Pick the driver at runtime with `process.env.NODE_ENV` and your local app behaves like the deployed one. `drizzle-orm/postgres-js` (with `postgres`) works too and is slightly faster on cold connects, but `node-postgres` is the closest one-to-one swap. ### Why does drizzle-kit push warn about neon serverless WebSocket against local Postgres? `drizzle-kit push` and `drizzle-kit migrate` use `node-postgres` internally regardless of which Drizzle driver your app uses. If your `drizzle.config.ts` points at a Neon serverless or pooled URL, drizzle-kit prints a WebSocket warning and falls back to TCP. Give it a `pg`-compatible URL (Neon's unpooled direct connection or `postgresql://postgres:postgres@localhost:5432/postgres` for local) and the warning goes away. Do not switch the production app to `pg` just to silence drizzle-kit. ### Why does my Neon seed work locally but fail in CI? The two most common causes are using the pooled URL in CI (switch to unpooled for seeds) and a missing `sslmode=require` in the environment variable. Also check that migrations ran before the seed, since CI often skips the migrate step when databases are recreated. ### What's the best test data generator for a Neon branch? The best fit for a Neon branch survives a reset without a file to restore, regenerating the branch's tables with valid FKs in a single run no matter what migrations have landed since. Seedfast does that against the branch's unpooled URL, reading whatever schema the branch holds at that moment and generating rows that fit it. Raw `psql` or ORM seeds remain the right choice for static reference data that should live in git and survive a reset by being committed. ### How do I keep Neon branch seed data current when the schema changes? Re-run Seedfast after migrating the branch. It reads the new tables and columns and generates valid rows for them, so there's no file to hand-edit and nothing to remember after the migration lands. A static `seed.sql` or ORM seed needs a manual update for every added column or foreign key. ## Related guides - [How to seed a database: PostgreSQL practical guide](/blog/seed-database): the framework-agnostic version of this article, covering raw SQL, Prisma, Drizzle, TypeORM, and `node-postgres` - [Database seeding: methods and best practices](/blog/database-seeding): the conceptual companion covering reference vs test data, idempotency, and when seed files stop scaling - [Database seeder tools compared](/blog/database-seeder): quick reference for Laravel, Prisma, Drizzle, TypeORM, and EF Core seeders next to standalone tools - [Seed file maintenance](/blog/seed-file-maintenance): why static seed files fall out of sync with your schema and what to do about it - [Microservice database seeding](/blog/microservice-seeding): when one Neon project isn't enough and you're seeding multiple databases that reference each other - [How to seed a Supabase database](/blog/supabase-db-seed): the Supabase sibling to this guide, covering `seed.sql`, `supabase db reset`, `auth.users`, and Supabase preview branches - [PgBouncer transaction mode: what the next client inherits](/blog/pgbouncer-transaction-mode): the measured matrix behind the pooled-URL advice above, showing which session state a transaction pooler keeps on the backend for whoever connects next - [Neosync alternative](/compare/neosync-alternative): migration guide for teams who ran Neosync against Neon and are now orphaned after the September 2025 Grow Therapy acquisition - [Snaplet Seed alternative](/compare/snaplet-seed-alternative): migration guide for teams whose `@snaplet/seed` setup stalled after the 2024 shutdown, with the move to schema-aware seeding - [Get started with Seedfast](/docs/seed-your-database): connect to your Neon database and run your first schema-aware seed --- # Data Seeding Tools for Regulated Teams: What Actually Works > Source: https://seedfa.st/blog/data-seeding-tools > Last updated: 2026-07-22 Every development team needs a populated database. For most teams, the answer is a seed script or a Faker-based fixture. That works until the schema grows, migrations arrive weekly, and the seeder becomes dead weight nobody wants to own. For teams in regulated industries, the answer is harder. [HIPAA](https://www.hhs.gov/hipaa/for-professionals/privacy/index.html), [PCI DSS](https://www.pcisecuritystandards.org/), [GDPR](https://gdpr-info.eu/), and [SOC 2](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) restrict how production data can be used outside production environments. Copying the prod database to a developer's laptop or [to a staging server](/blog/staging-without-prod-data) is more than a bad practice here. Once real PII or cardholder data is in scope, it can put you in breach, and the stakes are concrete, with IBM putting the global average cost of a data breach at $4.44 million in 2025, and $10.22 million in the United States ([IBM Cost of a Data Breach Report 2025](https://www.ibm.com/reports/data-breach)). That math is why a test data management tool for regulated industries has to keep real records out of lower environments entirely. These teams need data that was never real to begin with. This guide covers the full spectrum of data seeding tools: what each approach is, where it holds up, and where it breaks. The decision tree at the end will help you pick the right one for your situation. ## Key Takeaways - The choice is really between a few approaches, not between individual seeding tools: DIY scripts (Faker and ORM seeders), web generators, enterprise anonymization, and schema-aware generators - Teams in regulated industries (fintech, healthcare, insurance, etc.) can't use production data at all, so anonymization tools and schema-aware generators are the only viable paths - Enterprise anonymization tools (Tonic Structural, Delphix, K2View) require custom enterprise contracts and production database access. Seedfast and Tonic Fabricate fill that gap, generating from the schema for teams that don't want the overhead of a production pipeline - Because Seedfast reads the live schema on every run, a migration lands in the next dataset rather than in a ticket to go fix the seed script ## The real problem with test data The hard part of test data isn't generating fake names and emails, since any library handles that already. The hard parts are: **Relational integrity.** Inserting an `order_item` requires an existing `order`, `product`, and `price`, just as a `user` might need a `team`, a `role`, and a `subscription`. Most data seeding tools ignore foreign keys entirely, producing rows that fail on insert or orphaned records that make your app behave unpredictably. Keeping [referential integrity intact](/blog/referential-integrity) means every insert has to land in the right order, and [circular foreign keys](/blog/circular-foreign-key-seed) make even that impossible to do by hand. **Schema evolution.** Schemas change constantly, and a new `NOT NULL` column, an added foreign key, or a renamed table breaks a seed script written against last week's schema. The [maintenance cost compounds with every migration](/blog/seed-file-maintenance). **Realistic distributions.** Uniform random data looks obviously fake, because real databases have skewed distributions, where a few customers make most purchases, transaction amounts cluster around common price points, and activity varies by time of day. Dashboards, reports, and business logic all behave differently against that realistic shape than against uniform noise. **Compliance constraints.** Teams in fintech, healthcare, and other regulated industries can't use production data in development. For these teams, "just copy prod" isn't an option. They need [data that was never real to begin with](/blog/generate-test-data-with-ai). ## How teams actually solve this (the full spectrum) Data seeding tools are only one slice of a bigger picture, since the strategy layer above them is [test data management](/blog/test-data-management), which decides where test data comes from and how it stays valid. Between them, these approaches cover almost every team, and the table below maps them on what actually decides the choice, whether the tool resolves foreign keys, whether it needs production access, how much it costs to maintain, and what it costs to buy. | Approach | Foreign-key handling | Production access | Maintenance | Pricing | | ------------------------------------------------------- | -------------------- | ----------------- | --------------- | ----------------------------------------------------------------- | | Faker libraries | Manual wiring | Not required | High | Free | | ORM seeders (Prisma, Laravel, EF Core) | Manual via ORM API | Not required | Medium–high | Free | | Web generators (Mockaroo) | None | Not required | Medium | $0–$7,500/yr (mockaroo.com/pricing, as of Jun 2026) | | Copy + anonymize production (Tonic Structural, Delphix) | Preserved from prod | **Required** | Low after setup | Enterprise / custom | | Schema-aware generators (Seedfast, Tonic Fabricate) | Automatic | Not required | Low | Free plan or free tier, then paid plans | Snaplet Seed and Neosync once filled out the schema-aware row, but both are no longer actively maintained (covered below). Of the maintained schema-aware options, [Seedfast](https://seedfa.st/) reads your live schema on every run and generates relational data from it, with no prod data and no seed scripts to maintain. You point it at a database and it adapts as your schema changes. You can [seed a database in under five minutes](/docs/seed-your-database), or read on for how each approach compares. ## Faker libraries Faker (available in JavaScript, Python, PHP, Ruby, and Java) generates random values like names, emails, addresses, and dates. You call `faker.person.fullName()` and get a plausible-looking string. ```javascript import { faker } from "@faker-js/faker"; const users = Array.from({ length: 50 }, () => ({ name: faker.person.fullName(), email: faker.internet.email(), createdAt: faker.date.past(), })); ``` Faker is the most common starting point, since it's fast, zero-config, and works with any database, but the limitation runs deep. It generates values only for the columns you specify, without knowing your schema, resolving foreign keys, or inserting anything, so you write the insert logic yourself, wire up table dependencies by hand, and update that code every time the schema changes. Flat tables with few dependencies are where Faker earns its keep, covering quick prototypes and unit tests that need only a handful of records. Push it onto a schema with 10+ related tables, though, and the FK wiring becomes its own codebase, with every migration that touches a seeded table demanding a script update. At 20+ tables with weekly migrations, maintaining the Faker-based seeder turns into a recurring sprint tax. For teams in regulated environments, Faker solves the wrong problem entirely; random data isn't enough, and what's needed is data your compliance team has approved. ## ORM built-in seeders Every major ORM ships with a seeding mechanism. Prisma uses `seed.ts` with `prisma.$transaction`, while Laravel builds `DatabaseSeeder.php` around factories. EF Core offers `HasData`, and Rails falls back on `db/seeds.rb`. ```typescript // Prisma seed.ts const team = await prisma.team.create({ data: { name: "Engineering" } }); await prisma.user.create({ data: { email: "alice@example.com", teamId: team.id }, }); ``` ORM seeders are a step up from Faker, since the ORM handles insert logic and relationships can be defined through its API. The core constraint remains the same, though, because you still define the data manually, and a new `NOT NULL` column breaks the seeder until someone updates the script. ORM seeders work well for stable schemas with small teams, and for deterministic reference data (specific roles, feature flags, or config) that must match exactly across environments. They break down on complex, evolving schemas, where the seeder becomes maintenance overhead, since the ORM adds convenience but doesn't eliminate the fundamental problem of manually specifying data that drifts from the schema. ## Web-based generators Mockaroo, GenerateData, and similar tools let you define columns through a web form and export synthetic data as CSV, JSON, or SQL. These are fast for one-off exports and useful when you need data without writing code, though the ceiling is that they don't connect to your database, don't understand foreign keys, and produce flat rows. Exporting means manually importing the rows afterward and wiring up the relationships yourself. Web generators earn their keep on simple tables, mock API responses, and quick datasets for demos or presentations. Any relational database with foreign keys pushes past that ceiling, since the export-then-import workflow doesn't scale past a few tables. ## Production data anonymization At the opposite end of the spectrum, enterprise anonymization tools (Tonic Structural, Delphix, K2View, Informatica) connect to your production database, mask or replace sensitive fields, and produce a de-identified copy. This approach has a clear advantage, since the resulting data inherits the real schema structure, distributions, edge cases, and volumes of your production system. Business logic that depends on specific data patterns works correctly because the data came from a system where that logic was already running. The constraints are equally clear: - **It requires production access.** Someone has to approve a connection from the anonymization tool to your production database. In many organizations, this requires security review, VPN configuration, and ongoing credential management. - **It's enterprise-priced.** These tools are sold through sales cycles with custom contracts. There are no self-serve plans or published prices. The purchasing process alone can take longer than the original problem. - **Setup takes weeks.** You configure masking rules per column, validate that anonymized data preserves referential integrity, and set up refresh schedules. This is a project, not a feature toggle. - **It still processes production data.** Even though the output is anonymized, the pipeline processes real PII. For some compliance frameworks, the existence of that pipeline is itself a risk that needs to be managed. Anonymization earns its place at large enterprises that already have production databases, dedicated data teams, and regulatory requirements that specifically demand prod-derived test data. It's overkill for teams that need a working database for local development, CI/CD, and demos without the overhead of a production pipeline, or for companies without production data yet, when the security process to approve a prod connection takes longer than the project itself. If you've been quoted by an enterprise vendor for this job, the [GenRocket alternative](/compare/genrocket-alternative) guide covers the self-serve path for seeding a Postgres test database. ## Data masking vs synthetic data Data masking transforms real production data, connecting to prod, replacing sensitive fields per column, and outputting a de-identified copy. Synthetic data is generated from scratch, from a schema or a description, so no row ever corresponds to a real person or transaction. The split that matters in practice is access, since masking needs a production connection and a PII pipeline while synthetic generation needs neither. That distinction sorts the tools cleanly, with the masking side matching the production-data anonymization category above, where Tonic Structural, Delphix, K2View, Informatica, and open-source Greenmask all start from real records. The synthetic side generates instead. Tonic Fabricate and [Seedfast](https://seedfa.st/) build relational data from your schema, while Faker or ORM seeders produce flat synthetic values you wire up by hand. Seedfast sits firmly on that synthetic side, reading your schema and generating from it without ever connecting to or processing a production dataset, so nothing real enters the pipeline in the first place. Fabricate is the closest direct competitor here; the [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate) comparison covers the workflow and pricing differences between the two. Masking still wins when your testing depends on the exact distributions and edge cases of your specific production system, the analytics and ML scenarios covered in the [anonymization section](#production-data-anonymization) above. For development, CI, and demos, where you need a valid, realistic database without a compliance pipeline, synthetic generation gets you there without ever touching production. A third option people reach for is "I'll just prompt an LLM to generate the rows," which works for one flat table and falls apart on a real schema. Because a raw LLM has no model of how your tables relate, it drifts. Ask for fifty orders, and it will reference a `customer_id` that was never inserted. Non-deterministic by nature, it also produces a different dataset on every run and bills per token on every regeneration. Seedfast reads the actual constraints out of your database and generates connected rows against them, which is the part a chat prompt never sees. A model still writes the individual values, so names and amounts read like real business data, while the structure comes from your schema rather than the model's guess. ## Schema-aware generators A newer category occupies the gap between free DIY tools and enterprise anonymization platforms, connecting to your database, reading the schema, and generating data that satisfies all of it automatically, without ever touching production data. The main distinction from anonymization tools is that they don't need a production database to connect to, working from the schema alone. For teams in regulated industries (fintech, healthcare, insurance), this is the difference that matters, since there's no real data in the pipeline at all, which means no PII to manage. One path detail is worth reviewing, though. Schema-aware generators that use AI (Seedfast included) send your schema definition (table and column names, types, constraints) to an AI provider to generate the data. Row values never leave your database, but if schema names are themselves sensitive, review that path against your security policy. For a closer look at how this plays out across a large schema and several environments, see [what enterprise database test data actually looks like](/blog/enterprise-database-test-data). **Snaplet Seed** (`@snaplet/seed`) is an open-source TypeScript library in this space, introspecting your PostgreSQL schema to generate a type-safe seed client. You write seed plans that describe the shape of the data: ```typescript import { createSeedClient } from "@snaplet/seed"; const seed = await createSeedClient(); await seed.users((x) => x(3, () => ({ orders: (x) => x(2, () => ({ order_items: (x) => x(3), })), })), ); ``` Snaplet Seed keeps the relationships valid, since you declare nested relationships and it fills them in for you. The values it generates are deterministic (via the copycat library) but not domain-realistic, so expect placeholder-style text for names and emails instead of values that look like real business data. Note that Snaplet the company shut down in 2024; the library is now maintained as open source under the Supabase community. If you're migrating off it, the [Snaplet Seed alternative](/compare/snaplet-seed-alternative) guide covers the move. For the Supabase-specific workflow (`seed.sql`, `auth.users`, preview branches, and where Snaplet Seed fits), see [How to Seed a Supabase Database](/blog/supabase-db-seed). **Neosync** was the second open-source schema-aware generator in this category, also covering production-data anonymization alongside synthetic generation. It was acquired by Grow Therapy in September 2025; its cloud service wound down, and the open-source repo was archived on August 30, 2025. Teams who picked Neosync specifically for the generation side are looking at the same migration choice Snaplet Seed users faced a year earlier. [The Neosync alternative guide](/compare/neosync-alternative) covers the paths out (Seedfast for generation, Greenmask for anonymization). **Tonic Fabricate** is Tonic.ai's synthetic data generation product. Like Seedfast, it generates data from scratch without production access, and it can connect to a live database to model from an existing schema. The differences are in the developer surface. Fabricate is a web/chat AI agent with a Python SDK/API and a data-load CLI, but no one-command CLI/MCP seed step that runs natively in a pipeline (its documented CLI loads already-generated data into a Postgres target), and it meters per AI token on top of the $29/month Plus plan (tonic.ai/pricing, as of July 2026). The schema and generation plan are configured in Fabricate rather than read fresh on every CLI run. **[Seedfast](https://seedfa.st/)** takes a different approach, letting you describe the business scenario you need instead of writing seed plans in code: ```bash # Local development seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" # CI/CD pipeline (see the full CI/CD guide: https://seedfa.st/docs/cicd-database-seeding) seedfast seed --scope "2 users with completed orders and one pending" # Load testing (see: https://seedfa.st/blog/load-testing-data) seedfast seed --scope "realistic store with 500 products, reviews, and varied order history" ``` Seedfast starts every run by reading the live schema, which is why there is no client to generate and no seed plan waiting to be updated after the next migration. What comes back is realistic relational data built entirely from that schema. Because no production row ever enters the pipeline, teams get a populated database without a PII path to document or a security review to sit through first. ## Which approach fits your situation **You're in a regulated industry and can't use production data.** This is the scenario that free tools can't solve and enterprise anonymization platforms are overkill for. Schema-aware generators (Tonic Fabricate and [Seedfast](/docs/seed-your-database)) both generate from scratch without production access, and both can connect to a live database. Where they differ is the developer surface: Fabricate is a web/chat agent (with an SDK/API and a data-load CLI) that meters per token, while Seedfast is one CLI command, and an MCP tool, that reads the current schema on every run and seeds in CI on [flat monthly plans](/pricing), adapting to migrations automatically. **You have a complex schema and no production data yet.** You can't anonymize what doesn't exist, and Faker only covers simple prototyping from there. Once the schema has real relational depth, Seedfast gets you a working database on day one by generating it from the structure you already have. **You have a simple schema (under 10 tables, few FKs) and it rarely changes.** An ORM seeder or Faker can work here, though even simple schemas tend to grow, and the switch to a schema-aware generator gets harder the longer you wait. **You need deterministic reference data (roles, feature flags, config).** Use a version-controlled SQL file or ORM seeder for the fixed data; roles, flags, and config should be exact and reproducible. Then use [Seedfast](/docs/seed-your-database) to fill the rest of your database with realistic relational data on top of that foundation. You can exclude tables that already have the data you need. **You need to preserve exact production data patterns for analytics or ML.** Use an anonymization tool. Schema-aware generators produce realistic data and can approximate production distributions, but if your testing depends on exact patterns and edge cases from your specific production system, prod-derived data will be more precise. **You need test data in CI/CD that survives schema changes.** Any tool that requires manually defined seed plans or scripts will break when migrations run. Seedfast re-reads the current schema at the start of each run, so [the seed step keeps up with your migrations](/docs/cicd-database-seeding) instead of turning into a "who broke the seeder" ticket the next morning. ## The maintenance cost most teams underestimate When evaluating data seeding tools, teams focus on setup time and ignore maintenance cost. Setup happens once, but maintenance happens every sprint. When a migration adds a required column, every seed script that touches that table breaks, every factory needs updating, and every Faker-based script needs a new field. At 20+ tables with weekly migrations, this becomes [hours per sprint](/blog/seed-file-maintenance) spread across the team, a cost that stays invisible in planning even though it shows up as velocity lost. Seedfast checks the schema on every run and takes most of that cost off the board. What you give up in exchange is fine-grained control over individual values, which most development and testing work can afford to lose. ## Frequently asked questions ### What are the main types of data seeding tools? There are five practical categories: Faker-style libraries, ORM built-in seeders, web generators like Mockaroo, enterprise production-data anonymizers like Tonic Structural and Delphix, and schema-aware generators like Seedfast and Tonic Fabricate. They differ mainly in whether they resolve foreign keys, whether they need production access, and how much maintenance they demand as the schema changes. ### What is the difference between a data seeding tool and an anonymization tool? A seeding tool populates a database with test data, generated or hand-written rows that satisfy the schema. An anonymization tool starts from real production data and masks or replaces sensitive fields to produce a de-identified copy. Because anonymization processes real production data and seeding never does, regulated teams scrutinize the anonymization pipeline far more closely. ### What is the difference between data masking and synthetic data? Masking works from data that already exists in production, replacing sensitive fields per column so the output is de-identified but still shaped by real records. Synthetic data skips that step entirely, since nothing in it was ever a real customer or transaction, which means no production connection and no PII pipeline to secure. The practical trade-off is precision versus exposure, since masked data mirrors your exact production distributions, while synthetic data trades precision for a dataset with no real customer behind any row. For the data-sensitive use cases, see [test data for fintech](/blog/test-data-for-fintech), [HIPAA test data](/blog/hipaa-test-data), and [test data for healthcare](/blog/test-data-for-healthcare). ### Is Seedfast a data masking tool? No. Seedfast generates synthetic data from your live schema. It reads tables, columns, constraints, and foreign keys, then builds valid relational rows from them. It does not connect to a production dataset, mask fields, or de-identify real records, so there is no PII pipeline to manage. If you specifically need a de-identified copy of your real production data, that is the masking category (Tonic Structural, Delphix, Greenmask), and Seedfast isn't part of it. ### What is a schema-aware data generator? A schema-aware data generator works from a database's current schema (its tables, columns, constraints, and foreign keys) to generate valid relational data directly, instead of from copied rows. Because it starts with the schema itself, it resolves foreign keys automatically, needs no production access, and adapts to migrations without a script rewrite. Seedfast reads that schema on every run from one CLI command against a connection string, while Tonic Fabricate has you configure the generation plan in a web agent first. ### What is the difference between Faker and a schema-aware generator? Faker generates individual random values for columns you name, but it does not read your schema, resolve foreign keys, or insert rows. You write and maintain that logic by hand. Seedfast starts from the whole schema instead, and the rows it writes are referentially valid without anyone hand-wiring them, so a migration changes what comes out rather than breaking the run. ### Do small teams need an enterprise anonymization tool like Tonic Structural or Delphix? Rarely. Enterprise anonymization requires production access, weeks of masking-rule setup, and custom contracts, which fit large orgs that must keep prod-derived patterns. Teams whose only need is a working database for local development, CI, or demos typically move faster with Seedfast, which builds the rows from the schema alone and never opens a connection to production. ### Can teams in regulated industries use production data for testing? Usually not. Once regulated data crosses into a lower environment, the frameworks that govern it (HIPAA, PCI DSS, GDPR, SOC 2) treat that copy as in scope just like production, so the copy that was meant to save setup time can instead put the team in breach. Teams under these regimes need data that was never real, whether that means anonymized production copies or data generated from the schema with no PII in the pipeline. Seedfast covers the second path, reading your Postgres schema and generating rows against it so no production record is ever in scope. For the audit-defensibility argument behind generating instead of masking, see why a [compliant test data tool](/blog/compliant-test-data) keeps data out of GDPR scope by construction. ### Do schema-aware generators require access to my production database? No. Schema-aware generators read the schema (tables, columns, constraints, foreign keys), not the data in it, so they can work against an empty or non-production database. Seedfast never copies rows out of a production system at all; it reads the schema definition and writes newly generated rows into whichever Postgres database you point it at. That distinction matters for regulated teams, since there is no PII in the pipeline when there was no real data in it to start with. ### Why do seed scripts break after a database migration? A seed script encodes the schema it was written against. When a migration adds a `NOT NULL` column, a new foreign key, or renames a table, the script's hard-coded inserts no longer satisfy the new shape and fail on the next run. Someone has to hand-edit the script every time the schema changes. Seedfast has nothing hard-coded to go stale, because the run after a migration reads the schema again and generates against the shape it finds. ### Which data seeding approach is best for CI/CD pipelines? Any approach with manually defined seed scripts will break when migrations run in the pipeline. Put `seedfast seed` after the migration step and Seedfast generates against the schema that step just created, which keeps the job green with no script for anyone to maintain. Keep deterministic reference data (roles, feature flags) in a versioned SQL file alongside it. Related guides: - [Database Seeder: ORM Built-ins vs Standalone Tools](/blog/database-seeder) - [Test Data Generation: 7 Methods Compared](/blog/test-data-generation) - [Test Data Management: Pillars, Tools, and a Framework](/blog/test-data-management), the strategy layer that decides where seed data comes from and how it stays valid - [How to Seed a Supabase Database](/blog/supabase-db-seed), the Supabase-specific walkthrough for teams choosing between `seed.sql`, Snaplet Seed, and schema-aware seeding - [Best AI Test Data Generator](/blog/best-ai-test-data-generator), the AI-vs-synthetic buyer comparison for application testing - [Tonic Fabricate vs Mockaroo vs Seedfast](/compare/tonic-fabricate-vs-mockaroo), the three-way generator head-to-head - [Compare all the test-data tools](/compare), the full test data tools comparison across use cases and features - [Get started with Seedfast](/docs/seed-your-database), under five minutes to your first seed, or [see the pricing](/pricing) --- # PostgreSQL Test Data: A Syntax Cookbook > Source: https://seedfa.st/blog/test-data-postgresql > Last updated: 2026-07-26 > **TL;DR:** PostgreSQL ships three primitives that cover most test-data work — `generate_series()` for single-table volume, `psql \copy` for bulk-loading prepared CSV, and `pg_dump --data-only` for carving production-shaped slices. The thing none of them solve is FK insert order across a moving schema, which is where hand-rolled approaches start needing a topological sort and start needing maintenance. ## Key Takeaways - `generate_series()` is the right tool for single-table volume and for synthesizing time-series-shaped rows; pair it with `random()` and `interval` arithmetic to get realistic dates and amounts in one statement - `psql \copy` is the fastest path for loading prepared CSV from a developer machine — it runs client-side, so it works against managed Postgres where server-side `COPY` would need superuser or `pg_read_server_files` - `pg_dump --data-only --table` carves a single table out of an existing database; combined with `--exclude-table-data` it gives you a structural restore with selective contents - FK insert order across more than a few tables turns into a topological sort against `pg_constraint`; deferrable constraints (`SET CONSTRAINTS ALL DEFERRED`) are the escape hatch for cyclic FKs - For the broader method comparison (Faker, masking, production copies, schema-aware generation), see the methods hub linked below — this page stays in PostgreSQL syntax land - Where the script approach runs out, schema-aware generation takes the catalog itself as its input, so a table added by this morning's migration is populated by this afternoon's seed with no topological-sort query to re-run --- This page is the syntax-level cookbook for PostgreSQL test data: the built-in functions, the `psql` meta-commands, the `pg_dump` invocations, and the catalog queries that let you reason about insert order against a real schema. For the broader question of which _method_ to use — Faker scripts, masking, production copies, schema-aware generation — see [test data generation: methods compared](/blog/test-data-generation), which is the methods hub. This page assumes you've already picked the method and you're staring at a `psql` prompt. Where the syntax runs out — FK insert order across a schema that keeps changing — the last section shows what schema-aware generation does instead. ## `generate_series()` — the workhorse [`generate_series()`](https://www.postgresql.org/docs/current/functions-srf.html) produces a one-column virtual table over an integer or timestamp range. It's the cheapest way to produce volume in a single table and it composes cleanly with `random()`, `interval` arithmetic, and JOINs against existing rows. The minimal pattern most developers write first: ```sql INSERT INTO users (email, created_at) SELECT 'user' || n || '@example.test', NOW() - (random() * interval '365 days') FROM generate_series(1, 100000) AS n; ``` Ten seconds for 100k rows on a laptop. No dependencies, runs anywhere `psql` runs. The timestamp form is what makes it useful beyond row count. Producing one event per day over a year: ```sql INSERT INTO events (occurred_at, kind) SELECT d, CASE WHEN random() < 0.3 THEN 'login' ELSE 'page_view' END FROM generate_series( '2025-01-01'::timestamptz, '2025-12-31'::timestamptz, interval '1 day' ) AS d; ``` Multi-table volume in one statement, when FK targets already exist: ```sql INSERT INTO orders (user_id, placed_at, total_cents) SELECT u.id, NOW() - (random() * interval '180 days'), (random() * 50000)::int + 100 FROM users u, generate_series(1, 5) AS o WHERE random() < 0.6; ``` That produces roughly 0–5 orders per user, on a 60% probability. The `users` table has to exist first — and that's where the limits begin. `generate_series()` is for volume against rows that already resolve. The moment you need to populate `users` → `orders` → `order_items` → `products` in a single seed run, you're back to writing application code or to a topological-sort query (below). Volume is worth the effort precisely because it surfaces [the bugs that only real test data catches](/blog/small-data-big-lies) — pagination off-by-ones, missing indexes, and timeout cascades that stay invisible at ten rows. ## `psql \copy` for bulk CSV When you already have a CSV — exported from a colleague's database, scraped from a public dataset, hand-crafted in a spreadsheet — [`psql \copy`](https://www.postgresql.org/docs/current/app-psql.html) is the fastest path in. Two things make it the right primitive for test-data work rather than the server-side [`COPY`](https://www.postgresql.org/docs/current/sql-copy.html) command: 1. It runs on the **client side**. The file path is local to wherever you're running `psql`, not local to the Postgres server. This is what makes it work against managed Postgres (RDS, Cloud SQL, Supabase, Neon) where you don't have shell access to the server filesystem. 2. It needs no special privileges. Server-side `COPY FROM 'filename'` requires `pg_read_server_files` or superuser; `\copy` just needs `INSERT` on the target table. Minimal invocation from a `psql` session: ```sql \copy users (email, created_at) FROM 'users.csv' WITH (FORMAT csv, HEADER true); ``` A few patterns that come up in practice: ```sql -- Skip the header row (the default is FORMAT text, which has no header concept) \copy products FROM 'products.csv' WITH (FORMAT csv, HEADER true) -- Different delimiter and a NULL-as-literal-string convention \copy events FROM 'events.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '\N') -- Only load specific columns; the rest take DEFAULT (works with serial IDs) \copy users (email, signup_source) FROM 'users.csv' WITH (FORMAT csv, HEADER true) -- Quote handling for free-text columns containing commas \copy comments FROM 'comments.csv' WITH (FORMAT csv, HEADER true, QUOTE '"', ESCAPE '"') ``` Gotchas worth flagging: - **Encoding mismatch is the most common silent failure.** If your CSV is UTF-8 with a BOM and your database is UTF-8 without, the first row's first column has an invisible `` prepended. Strip the BOM before loading, or use `iconv` to normalize. - **Errors halt the whole load by default.** `\copy` is transactional; one bad row rolls back the entire file. PostgreSQL 17 added `ON_ERROR ignore` to skip bad rows; on older versions, either pre-clean the CSV or load into a staging table with looser types and `INSERT … SELECT … WHERE` your way out. - **`\copy` runs your triggers.** `BEFORE INSERT` and `AFTER INSERT` triggers fire per row, exactly like a regular `INSERT`. Useful when you need a computed column populated at load time; a hidden cost when you forgot the audit-log trigger fires 100k times for a 100k-row CSV. ## `pg_dump` mechanics for test-data carving [`pg_dump`](https://www.postgresql.org/docs/current/app-pgdump.html) is best known for full backups, but a handful of flags turn it into a precise test-data extractor. Useful for the case where production-shaped data (with PII handled separately upstream) is the source of truth and you want to carve out the parts a developer needs without dragging the whole database. Data-only dump of a single table: ```bash pg_dump --data-only --table=public.products \ --no-owner --no-acl \ -h prod-host -U readonly -d appdb \ > products-data.sql ``` Schema for everything, data for a curated subset: ```bash pg_dump --schema-only --no-owner -d appdb > schema.sql pg_dump --data-only \ --table=public.users \ --table=public.products \ --table=public.categories \ --no-owner -d appdb \ > seed-data.sql ``` Inverse: dump the schema and all data _except_ the noisy tables: ```bash pg_dump \ --exclude-table-data='audit_*' \ --exclude-table-data='public.event_log' \ --no-owner -d appdb \ > app-without-audit.sql ``` Restoring to a fresh development database is then just two commands: ```bash createdb app_dev psql -d app_dev -f schema.sql -f seed-data.sql ``` Restoring that file into a database that matters is its own subject. A trigger that names its tables bare can stop the load even into an empty schema, and a target that already holds rows collides on primary keys before any trigger fires; [the pg_dump data-only restore guide](/blog/pg-dump-data-only-restore) works through both against real output. Limits of this approach: - `pg_dump` does not strip PII. If you pipe production data through it, the dump file is in HIPAA / GDPR / SOC 2 scope until it's masked, deleted, or kept inside a vetted environment. Anonymizers like Greenmask exist to slot in front of restore. - The custom format (`-Fc`) is what you want for selective restore with `pg_restore --table=…`; SQL format is what you want for grep-able human inspection. - `pg_dump` produces a snapshot of a moment in time. Re-running it as the seed source means re-pulling production. That's the maintenance trap, and it's why most teams that start here eventually move to a generator. ## FK insert ordering: the topological sort The hard problem with hand-rolled PostgreSQL test data isn't producing values — it's producing them in an order that preserves [referential integrity](/blog/referential-integrity): every child row's foreign key has to point at a parent row that already exists. A six-table schema with three FK chains is already past the point where humans get this right by eye. The [`pg_constraint`](https://www.postgresql.org/docs/current/catalog-pg-constraint.html) catalog query that gives you the dependency graph for the public schema: ```sql SELECT child.relname AS child_table, parent.relname AS parent_table FROM pg_constraint c JOIN pg_class child ON child.oid = c.conrelid JOIN pg_class parent ON parent.oid = c.confrelid JOIN pg_namespace ns ON ns.oid = child.relnamespace WHERE c.contype = 'f' AND ns.nspname = 'public' ORDER BY child.relname, parent.relname; ``` Each row reads "child references parent" — the edges of a directed graph. A valid insert order is any topological sort of that graph, with parents inserted before children. A recursive CTE that assigns an `insert_order` to every public table — parents at level 0, their children at level 1, and so on: ```sql WITH RECURSIVE deps AS ( -- Anchor: every public table starts at level 0 SELECT c.oid AS table_oid, c.relname AS table_name, 0 AS level FROM pg_class c JOIN pg_namespace ns ON ns.oid = c.relnamespace WHERE c.relkind = 'r' AND ns.nspname = 'public' UNION ALL -- Recursive: a child sits one level deeper than its deepest parent SELECT child.oid, child.relname, d.level + 1 FROM deps d JOIN pg_constraint fk ON fk.confrelid = d.table_oid AND fk.contype = 'f' JOIN pg_class child ON child.oid = fk.conrelid WHERE d.level < 20 ) SELECT table_name, MAX(level) AS insert_order FROM deps GROUP BY table_name ORDER BY insert_order, table_name; ``` Sort the output by `insert_order` ascending and you've got a valid sequence. (The `WHERE d.level < 20` guards against infinite recursion on cyclic FKs, which the next paragraph covers.) When FKs are cyclic — `users.created_by → users.id`, or `orders ↔ shipments` where each references the other — topological sort has no valid order. The PostgreSQL escape hatch is **deferrable constraints**: declare the FK with `DEFERRABLE INITIALLY DEFERRED`, then wrap the whole seed in a transaction. Constraint checks run at [`COMMIT`](https://www.postgresql.org/docs/current/sql-set-constraints.html), so you can insert rows in either order. The cyclic case gets a full walkthrough in [circular foreign key seed](/blog/circular-foreign-key-seed), which compares three workarounds against real terminal output. ```sql ALTER TABLE orders ADD CONSTRAINT orders_shipment_fk FOREIGN KEY (shipment_id) REFERENCES shipments(id) DEFERRABLE INITIALLY DEFERRED; BEGIN; -- Insert in any order; the constraint is checked at COMMIT INSERT INTO orders (id, shipment_id) VALUES (1, 1); INSERT INTO shipments (id, order_id) VALUES (1, 1); COMMIT; ``` If the constraint already exists as non-deferrable, you can toggle it in place — no drop required (PostgreSQL 9.4+): ```sql ALTER TABLE orders ALTER CONSTRAINT orders_shipment_fk DEFERRABLE INITIALLY DEFERRED; ``` (`ALTER CONSTRAINT` works on foreign keys for the deferrable attributes only; you can't change the referenced columns this way.) For one-off seeding, [`SET session_replication_role = replica`](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE) disables FK and trigger enforcement for the session entirely. It bypasses checks rather than deferring them, and it requires superuser (or, on PostgreSQL 15+, a non-superuser explicitly granted `SET` on the parameter). That's a `pg_dump`-restore pattern, not a development-loop pattern. What the parameter leaves switched on, and how it compares with `DISABLE TRIGGER` and the newer `NOT ENFORCED`, is measured cell by cell in [the session_replication_role matrix](/blog/session-replication-role). This is the part of the problem where hand-rolled scripts start needing real engineering. The query above gives you the order, but it doesn't generate the data. The data still has to be produced with valid types, plausible values, and FK column values that match the IDs you just inserted upstream. Typing those values yourself carries a small tax of its own, since a name like `O'Brien` closes the literal at the apostrophe unless the quote is doubled, which is what [the string literal escaper](/tools/postgres-escape-string) does to a whole block of them at once. That's the maintenance cliff most teams hit around 15–20 tables. ## Foreign key seed data: three problems that look alike Foreign key seed data goes wrong in three ways that produce similar errors and need different fixes. Insert ordering, the topological sort above, is this page's territory, and it covers every acyclic schema. Truly circular graphs, where two tables reference each other and no valid order exists, need [the circular foreign key patterns](/blog/circular-foreign-key-seed) instead — `DEFERRABLE` constraints or the nullable two-phase insert. And when the question is what the constraint actually promises rather than how to satisfy it, the [referential integrity](/blog/referential-integrity) explainer holds the definitional ground. Seed data with foreign keys that fails on insert is almost always the first case; seed data that cannot be ordered at all is the second. ## When PostgreSQL test data outgrows the script approach The three primitives above cover most of what PostgreSQL gives you for test data at the syntax level. Past a certain schema size, the bottleneck stops being syntax and starts being _the rate at which your schema changes_ versus _the rate at which someone updates_ [_the seed script_](/blog/postgres-seed-script) — the idempotency, sequence resets, and FK-ordered inserts that keep it running after the next migration. [Seedfast](https://seedfa.st/) reads the live schema on every run and generates valid, connected rows from a plain-English scope: ```bash seedfast seed --scope "an e-commerce database with 500 products, realistic order history, and a mix of customer account ages" ``` The migration story is the part scripts don't survive. Add a `referrals` table next sprint, and a Seedfast run picks it up automatically; a hand-rolled script needs the topological-sort query re-run and the data-generation code updated for the new table. A free plan is available — [see pricing](/pricing) or [run your first seed](/docs/seed-your-database). For the broader landscape — _why_ you'd use schema-aware generation rather than Faker, masking, or `pg_dump`-and-anonymize — that comparison lives in the [test data generation methods hub](/blog/test-data-generation), and once the question narrows from method to product, the [Postgres test data generator guide](/blog/best-postgres-test-data-generator) ranks the candidates on schema-awareness and FK validity. ## Frequently asked questions ### How do you generate test data in PostgreSQL? The PostgreSQL-native tools are `generate_series()` for single-table volume (`INSERT INTO users SELECT 'user'||n FROM generate_series(1, 100000) AS n`), `psql \copy` for bulk-loading CSV from your local machine, and `pg_dump --data-only --table=…` for carving subsets out of an existing database. Past a few tables with FKs, all three need help with insert order — a topological sort against `pg_constraint` gives you the right sequence; cyclic FKs need `DEFERRABLE INITIALLY DEFERRED`. For the method-level decision (script vs. masking vs. schema-aware generation), see the [test data generation hub](/blog/test-data-generation). ### What does `\copy` do that `COPY` doesn't? `\copy` is a `psql` meta-command that streams data over the client connection, so the CSV file lives on the machine running `psql` rather than on the database server. `COPY FROM 'filename'` is a server-side SQL command that reads the file from the server's filesystem, which requires `pg_read_server_files` (or superuser) and is unavailable on most managed Postgres services (RDS, Cloud SQL, Supabase, Neon). For developer-side bulk loads against managed Postgres, `\copy` is the practical choice. ### How do I handle FK insert order in PostgreSQL test data? Two patterns. For acyclic FKs, derive the dependency graph from `pg_constraint` (catalog query in the topological-sort section above) and insert in level order — parents first, children second. For cyclic FKs (`users.created_by → users.id`, or two tables that reference each other), declare the FK as `DEFERRABLE INITIALLY DEFERRED` and wrap inserts in a single transaction; PostgreSQL checks the constraint at `COMMIT` rather than at each row. Hand-rolling this works at small scale and becomes a maintenance burden as the FK graph grows. ### Can I seed a Postgres database without writing SQL? Yes. [Seedfast](/docs/seed-your-database) connects to your PostgreSQL database, reads the schema there, and writes valid, connected rows from a plain-English scope, which leaves no `seed.sql` and no factory code in the loop. See [database seeding](/blog/database-seeding) for the broader workflow. ## Related guides - [Test Data Generation: Methods Compared](/blog/test-data-generation) — the broader, non-PostgreSQL-specific decision matrix across all approaches - [Database Seeding: From Your First seed.sql to Fully Automated](/blog/database-seeding) — the broader context for how test data fits into seeding workflows - [Database Seeder: 7 Tools and Per-ORM Quick Reference](/blog/database-seeder) — the seeder tool itself (CLI/library choice), not the generation method - [Best Postgres Test Data Generator: How to Choose](/blog/best-postgres-test-data-generator) — the tool-shopping counterpart to this syntax cookbook, comparing generators on schema-awareness and FK validity - [Circular Foreign Key Seed: Three Workarounds That Actually Run](/blog/circular-foreign-key-seed) — the cyclic-FK case the topological sort can't order, with deferred constraints, nullable back-edges, and data-modifying CTEs compared - [Postgres Seed Script: Survive the Next Migration](/blog/postgres-seed-script) — turning these primitives into a maintained `seed.sql`: idempotency, sequence resets, and FK-ordered inserts - [Seed File Maintenance: When Your Seeds Fall Out of Sync](/blog/seed-file-maintenance) — what happens to hand-rolled scripts over time - [Load Testing With an Empty Database? Here's Your Problem](/blog/load-testing-data) — why these volumes matter: an empty database makes every load-test number fiction - [Get started with Seedfast](/docs/seed-your-database) — connect to your PostgreSQL database and run your first schema-aware seed --- # Database Seeder: 7 Tools and Per-ORM Comparison > Source: https://seedfa.st/blog/database-seeder > Last updated: 2026-09-10 Laravel's `DatabaseSeeder` class, Prisma's `seed.ts` script, `drizzle-seed`, EF Core's `UseSeeding`, Mockaroo's CSV export, and standalone tools like Seedfast that read the live schema all count as database seeders, meaning anything that gets an initial dataset into place before the application runs against it. They all do the same job, but the shapes and the maintenance bills diverge fast once the schema starts moving. This is the practical comparison of database seeder tools: what each one is, what it costs to keep alive, and how it handles the two things that actually break seeders, foreign keys and schema drift. There is no one best database seeding tool that wins for every stack, so this database seeder comparison ranks the options on the axes that decide it. For the foundational concept of seeding itself, see [database seeding methods and best practices](/blog/database-seeding). ## Key Takeaways - "Database seeder" covers everything from a 20-line SQL file to schema-aware generators that introspect your live database. They are not interchangeable. - ORM built-in seeders (Laravel, Prisma, EF Core, Drizzle) are coupled to your schema definition, so most schema changes also require seed-code changes. Some break loudly, some silently. - Standalone tools split into two camps. Form-based generators like Mockaroo work one table at a time, while Seedfast and drizzle-seed read a schema and write connected rows off it. - The single biggest cost driver is schema change frequency. If you ship migrations weekly, the right seeder is the one that doesn't ask you to update a file each time. - Seedfast re-reads the live Postgres schema on every run, so the seed that follows a migration already reflects it and nobody edits a file to make that happen. Its free plan carries $5 of credits a month, needs no card, and caps neither tables nor seeds. ## What is the best database seeding tool? No single best database seeding tool exists for every stack, since the right one depends on your stack, your schema size, and how often you ship migrations. The criteria that decide it stay consistent: does the seeder read the live schema, does it keep every foreign key valid including cycles, is it tied to one ORM, and what does it cost to maintain after the next migration? For a single small schema on one ORM, that ORM's built-in seeder is usually the best fit. Once migrations land weekly and the foreign-key count climbs, a schema-reading tool wins on maintenance, which is the slot Seedfast fills by re-reading the live Postgres schema on every run rather than holding a copy of it in a file someone owns. The comparison below scores each option on those axes, and the [best AI test data generator](/blog/best-ai-test-data-generator) roundup runs the same exercise for the AI-qualified slice of the market. Gartner estimates poor data quality costs organizations an average of $12.9 million per year ([Gartner, 2021](https://www.gartner.com/en/data-analytics/topics/data-quality)), and a seeder that silently inserts broken or orphaned rows pushes a slice of that cost into your test suite, where the bugs it hides surface later and cost more. ## At-a-glance comparison | Tool | Type | Reads live schema? | FK order resolved? | Works without ORM | License | Cost | | -------------------------------- | ----------------- | ---------------------- | -------------------------------- | ----------------- | ----------- | ----------------- | | Hand-written `seed.sql` | Manual | No | Manual | Yes | n/a | Free | | Laravel `DatabaseSeeder` | ORM built-in | No | Via factories | No (Eloquent) | OSS | Free | | Prisma `seed.ts` | ORM built-in | No (typed schema only) | Via nested writes | No (Prisma) | OSS | Free | | Drizzle `drizzle-seed` | Companion package | Reads Drizzle schema | Yes (from `references()`) | No (Drizzle) | OSS | Free | | EF Core `HasData` / `UseSeeding` | ORM built-in | No | Manual | No (EF Core) | OSS | Free | | Mockaroo | Web form | No | Manual via Datasets | Yes | Proprietary | Free tier + paid | | Seedfast | Standalone CLI | Yes (Postgres) | Yes (circular references included) | Yes | Proprietary | Free plan + paid | The two columns that separate a low-maintenance database seeding tool from a high-maintenance one are "reads live schema?" and "FK order resolved?". A tool that does not read the live schema needs you to keep a separate definition of it, and a tool that does not resolve FK order leaves you wiring the insert order by hand. No tool here wins every column, though. Mockaroo needs no install and runs in a browser, which none of the schema-readers do. The ORM seeders give you compile-time type safety against your schema that a generic tool can't match. ## What makes a database seeder break For the foundational concept, see [what is database seeding](/blog/database-seeding#what-is-database-seeding). Two failure modes break database seeders in practice. - **Foreign keys.** A seeder for one flat table is trivial, but add FK constraints, unique constraints, and nullable rules across 30 tables, and every relationship has to stay valid on insert, something real schemas rarely make easy once org charts, referral trees, or self-referencing hierarchies show up. Seeders that can't handle those loops deadlock mid-insert or fail a constraint they never saw coming. - **Schema drift.** Schemas change. Every new column, every new FK, every renamed table is a chance for the seeder to fail, and the failure rarely lands the day of the migration, showing up instead weeks later when someone runs a seed against a schema the seed code has never seen. Most ORM seeders solve foreign keys partway and schema drift not at all. Seedfast meets both from the other side, reading the live schema at the start of every run and writing rows that come out connected, so neither failure has a file to accumulate in. The seven tools below differ mostly on how they handle these two failure modes. ## ORM built-in seeders (per-ORM quick reference) Most major ORMs ship a seeding mechanism, or have a de-facto community package that fills the gap. They share a common idea (code that defines what to insert) and differ in command syntax, type safety, and how much factory tooling you get for free. ### Laravel `DatabaseSeeder` Laravel's seeder lives in `database/seeders/`. The entry point is the `DatabaseSeeder` class, which calls other seeders in order: ```bash # Generate a seeder class php artisan make:seeder UserSeeder # Run all seeders php artisan db:seed # Run a specific seeder file php artisan db:seed --class=UserSeeder ``` Laravel pairs seeders with **model factories** that handle a lot of relationship plumbing. With `Order::factory()->for(User::factory())->has(Item::factory()->count(3))`, the FK chain (user → order → items) is wired automatically, so you don't have to write the insert order by hand. That covers many practical cases. What factories don't do is read your live database, so a `NOT NULL` column added in a migration still means an updated factory definition or a runtime failure on the next seed. ### Prisma `seed.ts` Prisma puts seed code in a TypeScript or JavaScript file referenced from `package.json`: ```json { "prisma": { "seed": "tsx prisma/seed.ts" } } ``` ```bash npx prisma db seed ``` Prisma also runs the seed automatically on `prisma migrate dev` and `prisma migrate reset`, which is useful and occasionally surprising. The seed file gets full TypeScript type safety against your Prisma schema, so a renamed column produces a compile error rather than a silent runtime one. That catches breaks early, but it isn't the same as reducing the maintenance. For nested relationships, Prisma's `create` accepts a tree (`{ user: { create: { ... } } }`) and wires the relationships together for you. Beyond two or three levels deep this becomes hard to read; most teams end up factoring it into helper functions, at which point the seeder is essentially a small framework. Teams on Prisma's own managed Postgres product hit a wrinkle before any of that code runs, since the seed has to talk to the direct connection string, not the pooled one, or it fails with a cryptic prepared-statement error. [The Prisma Postgres seeding guide](/blog/prisma-postgres-seed) covers that connection-string trap alongside the plain-SQL path that skips Prisma's client entirely. ### Drizzle `drizzle-seed` `drizzle-seed` is the official companion package. Pass your Drizzle schema and it generates synthetic data with deterministic pseudo-random output (same seed → same data, useful for reproducible tests): ```typescript import { seed } from "drizzle-seed"; import * as schema from "./schema"; await seed(db, schema); ``` It reads `references()` declarations in your schema and keeps foreign keys valid automatically, better than a plain seed script for FK-heavy work. The catch is that it only sees tables you've defined in Drizzle. Raw-SQL tables, partial schemas, or non-Drizzle parts of your stack fall outside its view, and you'll need a second mechanism for those. The [drizzle-seed alternative](/compare/drizzle-seed-alternative) comparison covers where a live-schema reader fits when the database isn't only a Drizzle app. ### TypeORM seeder TypeORM doesn't ship a seeder in core. The community packages are `typeorm-extension` (current de-facto choice) and the older `typeorm-seeding`. Both add a seed CLI and a factory pattern reminiscent of Laravel's: ```bash npx typeorm-extension seed:run ``` Factories build entities that the seeder calls in whatever order you write, which works fine until the seed file becomes the highest-friction file in the repo. At that point, switching to a factory-based TypeORM seeder is mostly a sideways move, trading one form of manual wiring for another instead of removing it. ### EF Core `HasData` and `UseSeeding` EF Core gives you two patterns. `HasData` in model configuration embeds seed data into migrations: ```csharp modelBuilder.Entity().HasData( new Role { Id = 1, Name = "admin" } ); ``` `HasData` is deterministic and runs as part of `dotnet ef database update`. The downside is that seed data is pinned to a specific schema version, and changes to reference data require a new migration. EF Core 9 added the more flexible `UseSeeding` and `UseAsyncSeeding` callbacks on `DbContextOptionsBuilder`, which run during `Database.EnsureCreated()` and `Migrate()`: ```csharp options.UseSeeding((context, _) => { if (!context.Set().Any()) context.Set().AddRange( new Role { Name = "admin" } ); context.SaveChanges(); }); ``` This lands closer to the Laravel and Prisma model, where data lives in code, decoupled from migration files, and runs when the database is created or migrated. Both patterns still ask you to hand-edit the C# whenever a column is added or renamed. ### Mikro-ORM Mikro-ORM ships a seeder class similar in spirit to Laravel's, where you extend `Seeder`, implement `run()`, register seeders in your config, and run `npx mikro-orm seeder:run`. The ergonomics are clean, but the data and the schema upkeep are both still on you, same as every other factory-pattern seeder in this list. ## Standalone database seeder tools Outside the ORM ecosystem, a different category of tools does the same job without tying you to a framework. **Mockaroo** is a web-based generator. You define columns and types in a form, choose an output (SQL, CSV, JSON), and download. It supports relational data through "Dataset Columns" (you can link a column to values from another dataset you've defined), but it doesn't connect to your live database, so any schema change means re-doing the form. For a single flat table or quick mock CSVs, Mockaroo is fast. For a 30-table schema with active migrations, the manual upkeep makes it impractical. **Faker libraries** (Faker.js, FakerPHP, Python's Faker) generate realistic-looking strings, emails, dates, and numbers as building blocks that a seeder calls; they aren't seeders in their own right, since Faker doesn't model tables, constraints, or relationships. You use Faker inside a Prisma seed file or a Laravel factory; on its own it has no opinion about your schema. **Snaplet Seed** and **Neosync** are standalone tools in the same broad category as Seedfast, generating data from your schema rather than from a hand-maintained file. That's the same category behind the growing list of [MCP servers for test data](/blog/mcp-test-data), which plug generation straight into an agent's tool calls instead of a CLI. Both have their own strengths and tradeoffs (we've written detailed comparisons against [Snaplet Seed](/compare/snaplet-seed-alternative) and [Neosync](/compare/neosync-alternative) if you're evaluating them). If your stack is Postgres specifically, the schema-aware options are worth comparing on their own terms in the [best Postgres test data generator](/blog/best-postgres-test-data-generator) guide. **Seedfast** is a database seeder that reads your live PostgreSQL schema and generates a valid, connected dataset automatically. Instead of defining how to insert data, you describe the dataset you want: ```bash seedfast connect seedfast seed --scope "VC fund tracking limited partners and portfolio companies" ``` Here's what running that on a real schema actually looks like: ```text $ seedfast seed → Database: postgres → Connection: postgresql://*:*@localhost:5432/postgres?sslmode=disable ✓ Tables to seed: 10 Seeding scope • public.funds 5 records • public.limited_partners 10 records • public.commitments 15 records • public.capital_calls 20 records • public.companies 30 records • public.founders 60 records • public.investments 25 records • public.valuations 40 records • public.exits 10 records • public.distributions 15 records Total: 230 records Do you agree with this scope? → Continuing with the proposed scope ✓ Seeded 10 tables (230 rows) in 1m 34s → View stats: https://seedfa.st/dashboard ``` _(The free plan carries $5 of credits a month, plenty to evaluate it on a real schema. No plan limits tables or seeds; the paid tiers differ only in how many credits they include, so see [pricing](/pricing) for the numbers.)_ There's no seed file to write and no factory definitions to maintain; the CLI reads every table, type, constraint, and FK, then generates valid, connected data that fits the domain you described. A VC fund tracker gets fund records that own commitments which fund capital calls; an e-commerce store gets categories that contain products that get ordered. Whether the stack runs Prisma, Drizzle, TypeORM, Eloquent, or raw SQL doesn't matter, since Seedfast isn't reading any of their schema files, and the next migration that adds a column still shows up correctly in the next `seedfast seed` run. ## ORM seeders head-to-head: drizzle-seed, Prisma, Snaplet Seed, typeorm-extension drizzle-seed, Prisma's `db seed` hook, and typeorm-extension live inside an ORM, while Snaplet Seed stands alone and reads the database itself. The split that matters day to day is who writes the values. drizzle-seed and Snaplet Seed ship a generator that fills columns for you; Prisma's hook and typeorm-extension's factories run code you write, so the values are only ever whatever that code produces. | Tool | Who writes the values | Deterministic? | Maintenance status | Needs an ORM schema object? | | ------------------- | ---------------------------------- | --------------------------------- | ---------------------------------- | --------------------------- | | `drizzle-seed` | The tool (fixed generator catalog) | Yes (fixed seed → identical rows) | Maintained (official Drizzle team) | Yes (Drizzle schema) | | Prisma `db seed` | You (your script, or Faker in it) | Depends on your script | Maintained (Prisma) | Yes (Prisma schema) | | `@snaplet/seed` | The tool (`@snaplet/copycat`) | Yes (copycat is deterministic) | Discontinued (shut down Aug 2024) | No (reads live Postgres) | | `typeorm-extension` | You (factory definitions) | Depends on your factories | Community-maintained | Yes (TypeORM entities) | drizzle-seed keeps foreign keys valid from `references()` but drops to filler for any column its fixed catalog doesn't recognize. Prisma's hook gives you compile-time type safety and no opinion on the values; typeorm-extension adds the factory-and-seeder pattern on top, and you still hand-write the order those factories run in. Snaplet Seed reads the live Postgres schema through a codegen step, though the project shut down in August 2024 and the tooling no longer tracks new Postgres or Prisma releases. Seedfast sits outside that split. It reads the live schema directly instead of a Drizzle object, a Prisma type, a factory, or Snaplet's generated client, and it generates the values itself from a plain-language scope, so switching ORMs, or running none at all, changes nothing about how the seed behaves. For a deeper two-tool comparison against drizzle-seed on its own, the [drizzle-seed alternative](/compare/drizzle-seed-alternative) page has the side-by-side workflow breakdown. ## How to choose a database seeder Most teams start with their ORM's built-in seeder and stay there until the friction becomes obvious. The transition point is reasonably predictable. | Situation | Best fit | | ---------------------------------------------- | ----------------------------------------------------------- | | 1–5 tables, rarely change | SQL file or ORM seeder class | | Active schema, multiple developers, single ORM | ORM seeder with factories | | 15+ tables, frequent migrations | Standalone schema-aware tool (Seedfast, on Postgres) | | Mixed stack (Drizzle + raw SQL, multiple ORMs) | Schema-reading tool, not ORM-coupled | | Reproducible test data needed | `drizzle-seed` (deterministic) or Seedfast with fixed scope | | Production-like data without PII | Seedfast with a domain scope, generating from the schema | These are recommendations from how teams actually shift over time, not hard rules. Some teams run very large ORM seed suites successfully, usually because they invested early in a factory framework and one or two engineers own its upkeep. So ask honestly how much time the team spends on seeds after migrations land. An answer of "almost none" means your current seeder is fine, while tracking down the one engineer who remembers the right order every time a new database gets onboarded means the seeder has become a liability. The "production-like data without PII" row is its own decision. Teams in regulated industries need realistic data that never touches production records, which is a different shortlist from a generic seeder. The [data seeding tools](/blog/data-seeding-tools) comparison covers that regulated/compliance angle, including where data masking and synthetic generation diverge. ## What to look for in a database seeder Five capabilities determine how the seeder will behave on a real codebase six months from now. **Valid relationships** comes first, tested by whether the tool can insert into a table that depends on three other tables without you having to specify the order yourself. If the answer is "you write the order," cycles will eventually find you and the seed will deadlock or violate constraints. **Schema change handling** is the second, and it's the one that quietly compounds. It shows up in one of three ways, breaking loudly with a clear error (best), breaking silently and inserting wrong data (worst), or adapting automatically without any intervention (also best, and where schema-aware generators land). **Scope control** matters once you have more than one use case. Local dev wants a small dataset while load tests want production-scale volume, and integration tests sit somewhere in between, needing just enough data to exercise edge cases. A seeder that gives you one fixed dataset per run forces you to fork it three ways. The last two matter just as much, even though they're quieter. - **Environment isolation** means the seeder can't leave shared state across parallel test runs in CI; per-database or per-schema seeding beats a globally shared "test fixtures" table every time. - **ORM independence** matters when your stack mixes ORMs or has raw-SQL tables, since an ORM-coupled seeder leaves blind spots that database-direct tools don't have. ## Best practices for database seeder tools For the general seeding hygiene (idempotency, volume sizing, CI integration, separating reference data from test data), see [database seeding best practices](/blog/database-seeding#database-seeding-best-practices). The points below are specific to choosing and operating a seeder tool. **Pick a seeder before you have 15 tables.** Migrating from hand-written seed.sql to a factory framework or schema-aware tool at 30 tables is painful, since every FK chain has to be re-wired. Choose earlier; switch costs are linear in table count. **Lock the seeder choice across environments.** Local dev, CI, and staging should run the same seeder. Per-developer variants ("I just use staging") are a leading indicator that the seed tool isn't actually working. **Run the seeder on every PR.** A seeder that only runs locally drifts from the schema, and CI is the cheapest place to catch it before that happens. ## Frequently asked questions ### What's the difference between a factory and a database seeder? A factory is a definition of how to build one record of a given type, the way a `UserFactory` knows how to create a `User` with realistic field values, while a seeder is the runner that orchestrates which factories to call and in what quantities to populate the database. In Laravel and TypeORM, factories and seeders are paired explicitly; in Prisma, the seed file plays both roles. Seedfast skips the factory layer entirely, reading the schema that already describes those records instead of asking you to restate it in code. ### Which database seeder should I use with Prisma? Prisma's built-in seed mechanism (the `prisma.seed` entry in `package.json` plus `npx prisma db seed`) is the right starting point. For schemas with frequent migrations or 15+ tables, Seedfast removes that sync cost, because it reads the live database rather than the Prisma schema, and new columns and tables show up in the next seed run without any code change on your part. ### Can a database seeder handle foreign keys automatically? Some can. Most ORM seeders ask you to define the insertion order yourself, though factory frameworks (Laravel, TypeORM) and nested-write APIs (Prisma) handle simple chains. Drizzle's `drizzle-seed` reads `references()` declarations in your schema and resolves FK order from there. Seedfast keeps relationships valid automatically, whether a table loops back on itself or several tables loop into each other, resolving those cycles including the ones that are `NOT NULL` on both sides, and it does that across schemas as well as within a single one. ### Is there a free database seeder tool? Yes. Laravel's seeder ships with the framework, Prisma's seed mechanism is free, and `drizzle-seed` is open-source on npm. Mockaroo offers a free option with row limits per download (check their pricing page for current terms). Seedfast's free plan is permanent rather than a countdown: no card, $5 of credits a month, tables and seeds uncapped. That is enough runway to try it against a real schema before paying for anything. ### What happens to seeds when the schema changes? Seed code that hard-codes column names or table structures usually needs to be updated when the schema changes. Type-checked seeders (Prisma, EF Core, TypeORM) catch most breaks at compile time; untyped seeders fail at runtime, sometimes silently if the change is a new nullable column with a default. Seedfast doesn't carry this cost at all, since it reads the live database and the next seed run picks up whatever the migration changed. ## Stop maintaining seed files [Seedfast](https://seedfa.st/) is the option once the team is done rewriting seed code every sprint, since there's no seed file or factory boilerplate to keep in sync as the schema moves, and it works across Prisma, Drizzle, TypeORM, Laravel Eloquent, EF Core, or raw SQL. [Start free, no card →](https://seedfa.st/docs/seed-your-database) ## Related guides - [Get started with Seedfast](/docs/seed-your-database), which connects to PostgreSQL and runs your first seed in under five minutes - [How to Seed a Database: PostgreSQL Practical Guide](/blog/seed-database), covering `psql`, Prisma, Drizzle, and TypeORM walkthroughs - [Seed file maintenance: when seed scripts become a tax](/blog/seed-file-maintenance), the cost side of hand-written seeds - [Writing a Postgres seed script that survives migrations](/blog/postgres-seed-script), a worked seed.sql with FK ordering, idempotency, and sequence resets before you pick a seeder - [Snaplet Seed alternative](/compare/snaplet-seed-alternative) and [Neosync alternative](/compare/neosync-alternative), comparisons against other schema-aware tools - [Database seeding methods overview](/blog/database-seeding), the broader topic if you need foundational concepts before tool selection --- # How to Seed a Database: PostgreSQL Practical Guide > Source: https://seedfa.st/blog/seed-database > Last updated: 2026-09-18 **How to seed a PostgreSQL database, short version: `psql -d mydb -f seed.sql` against a file of INSERT statements. The rest of this guide covers what that one-liner doesn't — Prisma, Drizzle, Docker, idempotency, CI, and what to reach for when the seed file stops keeping pace with your migrations.** ## Key Takeaways - Seeding a PostgreSQL database starts with `psql -f seed.sql` — fast and readable for stable reference data, though it gets brittle fast as the schema moves out from under it - Every ORM has a seeding mechanism (Prisma's `db seed`, Drizzle's custom scripts, TypeORM's data sources), but all of them require manual updates when the schema changes - `ON CONFLICT DO NOTHING` is the minimum for idempotent seeds; `TRUNCATE ... RESTART IDENTITY CASCADE` is the fast reset option for development - Local dev, CI, and staging rarely want the same seed — sizing one dataset for all three usually leaves it wrong for at least one of them - [Seedfast](https://seedfa.st/) reads the live database schema on every run, so the migration that would have broken a hand-maintained seed file changes nothing about the command you type You ran your migrations and the tables exist, so now you need data — users to log in with, products to browse, orders to see in the dashboard. Knowing how to seed database tables correctly is one of those skills you use constantly but rarely stop to think through. This guide is Node.js and PostgreSQL-focused: `psql` commands, Prisma, Drizzle, TypeORM, and raw `node-postgres`. If you're on Laravel, EF Core, or want the conceptual background first, [database seeding methods and tradeoffs](/blog/database-seeding) covers that ground. Here we focus on the commands you actually run. [Seedfast](https://seedfa.st/) fills a PostgreSQL database from the schema it finds there, with every foreign key in the result resolving to a row that exists. We'll get to it right after the basics. ## The simplest way to seed a database in PostgreSQL A seed file is just SQL with INSERT statements. Create a file, run it against your database. ```sql -- seed.sql INSERT INTO roles (id, name) VALUES (1, 'admin'), (2, 'editor'), (3, 'viewer'); INSERT INTO teams (id, name, created_at) VALUES (1, 'Engineering', NOW()), (2, 'Design', NOW()); INSERT INTO users (id, email, team_id, role_id, created_at) VALUES (1, 'alice@example.com', 1, 1, NOW()), (2, 'bob@example.com', 2, 2, NOW()); ``` Run it with psql: ```bash psql -d mydb -f seed.sql # or with a connection string psql "$DATABASE_URL" -f seed.sql ``` For local development with Docker: ```bash docker exec -i postgres_container psql -U postgres -d mydb -f seed.sql ``` Five tables of stable reference data is easy enough with a plain SQL file, but the same approach strains once the schema grows to 20 tables, foreign keys run four levels deep, and weekly migrations start reordering what has to insert first. ## Seeding with your ORM Most Node.js setups use an ORM or query builder with its own seeding convention. Pick whichever matches your stack. ### Prisma Create `prisma/seed.ts` and configure it in `prisma.config.ts`: ```typescript // prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const team = await prisma.team.upsert({ where: { id: 1 }, update: {}, create: { name: "Engineering" }, }); await prisma.user.upsert({ where: { email: "alice@example.com" }, update: {}, create: { email: "alice@example.com", teamId: team.id, roleId: 1, }, }); } main() .catch(console.error) .finally(() => prisma.$disconnect()); ``` ```typescript // prisma.config.ts import { defineConfig, env } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", seed: "tsx prisma/seed.ts", }, datasource: { url: env("DATABASE_URL") }, }); ``` Run it: ```bash npx prisma db seed # Reset and reseed in one step npx prisma migrate reset ``` If your Postgres is Prisma's own managed product rather than a self-hosted instance, this same command can fail in a way that has nothing to do with the seed script itself — a [dedicated Prisma Postgres walkthrough](/blog/prisma-postgres-seed) covers the direct-vs-pooled connection string wrinkle that trips people up first. ### Drizzle Drizzle has no built-in seed command — you write a Node.js script and run it directly: ```typescript // scripts/seed.ts import { db } from "../src/db"; import { teams, users } from "../src/schema"; async function seed() { const [team] = await db .insert(teams) .values({ name: "Engineering" }) .onConflictDoUpdate({ target: teams.id, set: { name: "Engineering" } }) .returning(); await db .insert(users) .values({ email: "alice@example.com", teamId: team.id, roleId: 1 }) .onConflictDoNothing(); } seed().catch(console.error); ``` ```bash tsx scripts/seed.ts # Or add to package.json scripts: "seed": "tsx scripts/seed.ts" npm run seed ``` ### TypeORM ```typescript // src/database/seed.ts import { AppDataSource } from "./data-source"; import { Team } from "../entities/Team"; import { User } from "../entities/User"; async function seed() { await AppDataSource.initialize(); const teamRepo = AppDataSource.getRepository(Team); const userRepo = AppDataSource.getRepository(User); const team = await teamRepo.save({ id: 1, name: "Engineering" }); await userRepo.save({ email: "alice@example.com", team, roleId: 1 }); await AppDataSource.destroy(); } seed().catch(console.error); ``` ```bash ts-node src/database/seed.ts ``` ### Raw SQL with node-postgres When you're not using an ORM: ```typescript // scripts/seed.ts import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function seed() { const client = await pool.connect(); try { await client.query("BEGIN"); await client.query(` INSERT INTO teams (id, name) VALUES (1, 'Engineering') ON CONFLICT (id) DO NOTHING `); await client.query(` INSERT INTO users (email, team_id, role_id) VALUES ('alice@example.com', 1, 1) ON CONFLICT (email) DO NOTHING `); await client.query("COMMIT"); } catch (e) { await client.query("ROLLBACK"); throw e; } finally { client.release(); } } seed() .catch(console.error) .finally(() => pool.end()); ``` ## Seed a PostgreSQL database with Seedfast, with no seed file to maintain Seedfast connects to your PostgreSQL database and reads the live schema (tables, columns, types, constraints, foreign keys), then generates a dataset that satisfies all of it. You describe the domain in plain English, and every row it writes comes out valid and connected. ```bash # Install npm install -g seedfast # or: brew install seedfast # Connect to your database seedfast connect # Generate data from the current schema seedfast seed --scope "small engineering team with a few projects and task assignments" ``` When a migration adds a new column or table, the next `seedfast seed` picks it up automatically, without a seed file to chase down. ```bash # Different scopes for different environments seedfast seed --scope "2 teams, 5 users, minimal product catalog" # dev seedfast seed --scope "3 users with 5 completed orders each" # CI test seedfast seed --scope "realistic e-commerce, 500 products, reviews" # staging ``` Seedfast talks to Postgres directly rather than through ORM abstractions, so it works alongside Prisma, Drizzle, TypeORM, and Sequelize without caring which one owns the schema. Run your migrations first, then run Seedfast. ## Making seeds idempotent An idempotent seed runs successfully whether or not the data already exists. This matters because seeds run in CI on every pipeline, and you want them to succeed on retry without manual cleanup. ### ON CONFLICT DO NOTHING The simplest approach — skip the insert if the row already exists: ```sql INSERT INTO roles (id, name) VALUES (1, 'admin'), (2, 'editor') ON CONFLICT (id) DO NOTHING; ``` It works when you don't need to update existing rows. ### ON CONFLICT DO UPDATE (upsert) When you want the seed data to reflect the latest values: ```sql INSERT INTO roles (id, name) VALUES (1, 'admin'), (2, 'editor') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name; ``` ### TRUNCATE + RESTART IDENTITY Wiping and reseeding from scratch is fast and reliable for development, though it means losing anything already in the tables: ```sql TRUNCATE roles, teams, users RESTART IDENTITY CASCADE; INSERT INTO roles (name) VALUES ('admin'), ('editor'), ('viewer'); INSERT INTO teams (name) VALUES ('Engineering'), ('Design'); INSERT INTO users (email, team_id, role_id) VALUES ('alice@example.com', 1, 1), ('bob@example.com', 2, 2); ``` `RESTART IDENTITY` resets sequences (auto-increment IDs back to 1). `CASCADE` truncates dependent tables in the right order so FK constraints don't block the truncate. Use this only when you're starting fresh and don't need to preserve existing data. ### COPY FROM for large seed datasets When you need to seed tens of thousands of rows, `INSERT` is slow. PostgreSQL's `COPY` protocol loads data from a file 5–10x faster: ```sql COPY products (name, price, category_id) FROM '/tmp/products.csv' CSV HEADER; ``` Or pipe data directly without a file: ```bash psql "$DATABASE_URL" -c "COPY products (name, price, category_id) FROM STDIN CSV" < products.csv ``` It's useful for staging databases that need thousands of realistic rows. For schemas with FK constraints between tables, still seed referenced tables first. ## Seeding by environment Different environments need different data volumes. Trying to run the same seed everywhere usually means either too little data in staging or too long a runtime in CI. ### Local development Local dev seeds should be small and fast, scoped to whatever feature you're actively building: ```bash # Enough to log in and navigate seedfast seed --scope "2 teams, 5 users per team, 10 products" # or a hand-written file for simple schemas psql "$DATABASE_URL" -f seeds/dev.sql ``` ### Test / CI CI wants focused datasets built for one scenario at a time, since each test suite only needs to seed what that scenario touches: ```bash # Minimal dataset for CI psql "$DATABASE_URL" -f seeds/test-baseline.sql ``` Or per-test using a tool that resets between runs: ```yaml # GitHub Actions - name: Run migrations run: npx prisma migrate deploy - name: Seed test database run: npm run seed:test - name: Run tests run: npm test ``` ### Staging Staging needs enough data to demo the application realistically, which is exactly where hand-written seeds stop scaling: a database for an e-commerce app might need 500 products, realistic price ranges, reviews that reference real products, and users with full order histories. ```bash # Schema-aware staging seed (no seed file to maintain) seedfast seed --scope "e-commerce with 500 electronics products, reviews, and 50 users with order histories" ``` ## When your seed breaks The most common cause of a broken seed is a migration that adds a `NOT NULL` column. Your INSERT statements don't include the new column, the database rejects them, and suddenly nobody on the team can run the app locally until someone fixes the seed file. That's because hand-written seeds are static, describing the schema at the moment you wrote them — and every subsequent migration drifts them a little further from reality. Fixing it always looks the same — find the failing INSERT, work out what the migration added or changed, then update, test, and commit the seed. On an active codebase with weekly migrations, this becomes a recurring task with no clear owner. There are two ways out: **Keep the seed file and add discipline.** Pair every migration PR with a seed-file update and make it part of your PR checklist. This works for teams with stable schemas and small seed files — and it stops scaling around 20 tables and monthly migrations. **Use [Seedfast](https://seedfa.st/).** It picks up new columns and tables the moment they exist, so there is nothing for a PR checklist to enforce and the seed can't quietly fall out of sync. The [best AI test data generator](/blog/best-ai-test-data-generator) comparison ranks it against the other schema-aware options if you want the survey before the commitment. ### What production schemas actually look like Seedfast has seeded 188 Postgres schemas since November 2025, which gives that "around 20 tables" threshold some empirical footing. The median schema carries 11 tables, roughly a third cross 20, a quarter carry 25 or more, and the largest reached 248, while a typical run wraps up within a few minutes of starting. Volumes stay fixture-sized for most runs, hundreds of rows rather than bulk loads, which is one reason the maintenance cost of a hand-kept file dominates long before insert speed does. These figures come from run metadata alone, with nothing read out of customer databases. ## Running seeds in CI/CD In a pipeline, seeding should be a single step after migrations, with no "run these three scripts in this order" choreography and no shared state between parallel test runs. ```yaml # .github/workflows/test.yml jobs: test: services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: testdb steps: - uses: actions/checkout@v4 - name: Install dependencies run: npm ci - name: Run migrations run: npx prisma migrate deploy env: DATABASE_URL: postgres://postgres:postgres@localhost/testdb - name: Seed database run: npm run seed # or: seedfast seed --scope "test baseline" env: DATABASE_URL: postgres://postgres:postgres@localhost/testdb - name: Run tests run: npm test env: DATABASE_URL: postgres://postgres:postgres@localhost/testdb ``` Keep seeds idempotent (`ON CONFLICT DO NOTHING` or upsert) so pipeline retries don't fail. For parallel test jobs, use isolated databases per job rather than a shared one — concurrent INSERTs from different test suites against the same tables produce inconsistent state. For deeper CI/CD patterns, see [CI/CD database seeding docs](/docs/cicd-database-seeding). ## Frequently asked questions ### How do I seed a PostgreSQL database quickly? For a simple schema, `psql -d mydb -f seed.sql` with a file of INSERT statements is the fastest path, and Prisma projects can run `npx prisma db seed` to execute `prisma/seed.ts` directly. Once a stack passes 10-15 tables or the FK relationships get complex, Seedfast (`seedfast seed --scope "..."`) generates valid data without a seed file at all. ### What is the difference between seeding and migrating a database? Migrations change database structure — creating tables, adding columns, setting constraints — and run in a fixed, versioned order, while seeds populate that structure with data and should be idempotent so they can run any time after migrations. They solve different problems, which is why keeping them separate is standard practice. ### How do I make my database seed idempotent? Use `ON CONFLICT DO NOTHING` or `ON CONFLICT DO UPDATE` in your INSERT statements so they don't fail when the row already exists. Prisma uses `upsert()`; Drizzle uses `.onConflictDoNothing()`. If you prefer a full reset, `TRUNCATE ... RESTART IDENTITY CASCADE` wipes the data and lets you re-insert from scratch. ### How do I seed a database in a Docker container? Pass the SQL file directly to psql inside the container: ```bash docker exec -i psql -U postgres -d mydb < seed.sql ``` Or mount the file and run it from inside: ```bash docker exec -it psql -U postgres -d mydb -f /docker-entrypoint-initdb.d/seed.sql ``` If the container runs under Compose, the [docker compose seed database](/blog/docker-compose-seed-database) walkthrough covers the healthcheck-gated version of the same step. ### Why does my seed break after a migration? It's a timing problem rather than a bug, since the seed was written against one version of the schema and every migration since has been free to move the target. The most common trigger is a new `NOT NULL` column with no default, where the INSERT is missing a value PostgreSQL now requires and the row gets rejected. Either patch the seed file after every migration that touches a required column, or point Seedfast at the database and let it read the schema at run time, so there is no stale copy of it to work from. ### How do I seed different amounts of data per environment? Keep environment-specific seed scripts or use a generator with environment flags. A common pattern: `seeds/dev.sql` for local (small and fast), `seeds/ci.sql` for test pipelines (minimal, focused), and a generated seed for staging (larger, realistic). Seedfast's `--scope` flag lets you describe the dataset in plain English for each environment without separate files. ## Related guides - [Database Seeding: From Your First seed.sql to Fully Automated](/blog/database-seeding) — the conceptual companion to this guide, covering what seeding is, why seeds break, and the full spectrum of approaches - [Database Seeder: 7 Tools and Per-ORM Quick Reference](/blog/database-seeder) — comparison of seeder tools side-by-side, with command syntax for Laravel, Prisma, Drizzle, TypeORM, and EF Core - [Seed File Maintenance: When Your Seeds Fall Out of Sync](/blog/seed-file-maintenance) — the long-term cost of maintaining static seeds on an active codebase - [Test Data Management: Pillars, Tools, and a Working Framework](/blog/test-data-management) — the strategy layer above seeding: where test data comes from, how it stays valid, and who governs it - [How to Seed a Neon Database](/blog/seed-neon-database) — Neon-specific guide covering pooled vs unpooled connections, branch inheritance, and CI/CD preview branches for serverless Postgres - [How to Seed a Supabase Database](/blog/supabase-db-seed) — Supabase-specific guide covering `seed.sql`, `supabase db reset`, `auth.users`, and preview branches - [Get started with Seedfast](/docs/seed-your-database) — connect to your PostgreSQL database and run your first schema-aware seed --- # How to Generate Test Data with AI Without Breaking Your Database > Source: https://seedfa.st/blog/generate-test-data-with-ai > Last updated: 2026-07-10 *The promise is simple: ask your AI agent for test data, get test data. The reality, until recently, has been messier.* > **TL;DR:** AI coding agents generate fake values well but fail at *relational* test data. Once a schema gets deep, the agent loses track of insert order, constraints, and unique keys, and leaves the database half-seeded. A better prompt won't fix that; the agent needs a schema-aware tool it can call. Seedfast reads your live Postgres schema and generates valid, connected rows from one natural-language request. ## Key Takeaways - AI agents are strong at generating plausible column values but weak at coherent multi-table data, because respecting foreign keys, unique constraints, and `NOT NULL` columns is a constraint-solving problem, not text completion. - The common failure mode is a half-seeded database: the agent's script fails partway through a deep foreign-key chain, then re-runs collide with the rows already inserted. - The reliable pattern is delegation. The agent describes what it needs in plain language, and Seedfast does the schema introspection and the constraint-aware generation behind that request. - Seedfast exposes a single `seedfast_run` MCP tool that reads the live schema on every call, so the data stays valid across migrations with no seed script to maintain. You already have an AI assistant. Claude Code, Cursor, Google Antigravity — one of them sits in your editor and writes most of the code you ship, [handling the database while vibe coding](/blog/vibe-coding-database) right alongside every other part of the app. So when it's time to generate test data with AI, the workflow seems obvious: ask the agent, get data, keep coding. And sometimes it works. But if you've tried to do this against a real schema — one with foreign keys, unique constraints, enum columns, and [five tables that all reference each other](/blog/circular-foreign-key-seed) — you know the reality is less charming than the pitch. This is a playbook for actually generating test data with AI, written for developers who already live inside an AI coding assistant and want their database populated without context-switching, without writing throwaway seed scripts, and without cleaning up half-inserted data the next morning. ## Why Your AI Agent Struggles with Seed Scripts Ask Claude or Cursor to "seed the orders table with related line_items and payments", and here's what usually happens: - The agent reads your schema files, then re-reads them, burning context on column types and FK relationships it'll need to recall again on the next prompt anyway. - It writes a Python or SQL seed script, the same `psql -f seed.sql` starting point covered in [how to seed a PostgreSQL database](/blog/seed-database). The script looks plausible, but it misses a `NOT NULL` column, or inserts an `orders` row pointing at a `user_id` that doesn't exist yet. - It runs the script. The first batch of inserts succeeds; somewhere in the middle, a constraint fails. Now your database is in a half-seeded state. - The agent rewrites the script and re-runs it. But the rows from the first attempt are still there, so the second run hits unique-key collisions, leaves orphaned FK references, or quietly duplicates data — old rows tangled with new ones. - After several rounds of patching and re-running, the data that did land is semantically incoherent: random strings for product names, mismatched relationships, totals that don't add up. Not the realistic scenario you needed for the feature you were actually building. This isn't hypothetical. When [Neon experimented](https://neon.com/blog/vibe-coding-with-ai-to-generate-synthetic-data-part-1) with letting Claude and GPT generate synthetic data directly, the models consistently struggled once the schema's relationships got deep — the point where constraint complexity exceeds what a model can track in its working context. Kent C. Dodds has [a whole tutorial](https://www.epicweb.dev/tutorials/ai-assistants/real-world-examples/realistic-database-seeding-with-ai) dedicated to the realistic version of this problem, because the naive approach doesn't survive contact with a production-shaped schema. Cursor users say the quiet part on the tool's own forum, where database threads complain the agent "does not acknowledge tables" it was never shown and ends up "generally not really that useful" for schema-dependent work. That reads less like a Cursor flaw than like the same ceiling wearing a different logo, since an editor agent sees the files you opened rather than the live database behind them, and the deeper the schema, the more of it sits outside the window. The problem isn't that LLMs are bad at generating fake data. They're great at generating fake data. The problem is that schemas have shape, and generating coherent data that respects that shape is a constraint-solving task, not a text-completion task. [Referential integrity](/blog/referential-integrity), meaning every foreign key pointing at a row that genuinely exists, is the part that breaks. [Seedfast](https://seedfa.st/) closes that gap by reading the live schema itself and generating rows against the constraints it finds there. ## What Actually Works: Give the Agent a Tool The trick isn't to get your agent to stop writing seed scripts. The trick is to give it a real tool it can delegate to — one that understands the constraint-solving part, so the agent can stay in the part it's good at: describing intent and reasoning about your feature. That's what MCP (Model Context Protocol) enables, and it's what [Seedfast MCP](/docs/mcp-setup-guide) exposes. Instead of the agent writing a Python script, it calls a single tool — `seedfast_run` — with a natural-language scope: *"seed the orders table with related line_items and payments"*. Seedfast handles schema introspection, relationship validity, and constraint-aware generation. Your agent reads the result and returns to whatever it was actually helping you build. The framing shift is small but load-bearing: the agent doesn't generate test data. **The agent orders test data from Seedfast**, which is the tool that specializes in generating it. The whole workflow stays conversational, but the hard part is offloaded. > "I'm building the order history feature. Seed the orders table with a few related line_items and payments, then show me a sample row" One tool call. Clean data. Context preserved. No half-written scripts to clean up by hand. *Want to try it? [Install Seedfast](/docs/mcp-setup-guide) — free plan, no credit card.* ## Generate Test Data with AI: Real Workflows Once seeding becomes a single conversational step, a lot of workflows you used to skip become cheap enough to do routinely. **Developer onboarding.** New team members spend hours setting up local databases. With Seedfast MCP configured in your project's `.mcp.json`, onboarding becomes a conversation: > **New dev:** "Set up my local database with test data for the main features" > **AI:** Creates plan, seeds 15 tables, reports completion in 45 seconds Combine with [Docker Compose](/blog/docker-compose-seed-database) and the whole local setup is one command plus one prompt. **Pre-demo environment setup.** Before a client demo, prepare the environment in a single sentence: > "Seed the staging database with tables: users, orders, products, payments" Always use the plan-then-execute pattern here — review what will be seeded before touching a shared environment. See the [MCP setup guide](/docs/mcp-setup-guide) for the full pattern. **Migration testing.** Before running migrations on production, test them against realistic data. Seed a copy of the schema, run the migration, and check whether the `ALTER TABLE` takes three seconds or three minutes against a million rows. The [migration testing guide](/blog/migration-testing) covers the full flow. **Reproducing production issues.** When debugging production bugs, recreate similar data shapes locally: > "Seed the accounts table with related subscriptions and billing_history tables" **Ephemeral CI databases.** Seedfast works well in ephemeral CI databases — spin one up per PR, seed it, run tests, destroy it. Each run gets fresh, isolated data with no shared state between jobs. **Multi-service development.** When working across microservices that share a schema: > "I'm working on the orders service. Seed only the tables owned by the orders schema, but include the users table from the shared schema for foreign key references" That prompt assumes the services still share one database. Once each service owns a fully separate Postgres instance with its own connection string, seeding turns into a coordination problem instead of a scoping one — [phased seeding across microservice databases](/blog/microservice-seeding) walks through the ID-contract and ordering strategies that case needs. ## Get Started If you want to generate test data with AI the way it was supposed to work — without broken scripts, half-seeded tables, or context bloat — the setup is short: 1. Install the CLI: `npm install -g seedfast@latest` 2. Get an API key at [seedfa.st](https://seedfa.st) (free plan, no card) 3. Add Seedfast to your AI assistant's MCP config — see the [MCP setup guide](/docs/mcp-setup-guide) for Claude Desktop, Cursor, VS Code, and Claude Code CLI 4. Ask your agent to seed something, and keep coding For the patterns that make seeding reliable at scale — scope writing, plan-then-execute, performance, error handling — see the [MCP Setup Guide](/docs/mcp-setup-guide). For credential hygiene and what crosses the wire to AI providers, see [Data Handling & Privacy](/privacy-policy). ## Frequently asked questions ### Can AI generate realistic test data for a database? AI models generate realistic individual values, names and emails and amounts, very well. They struggle with *relational* test data, where every row has to satisfy foreign keys and unique constraints across many tables at once. For coherent multi-table data, give the agent Seedfast to call instead, since it reads the live Postgres schema and generates the connected rows the agent would otherwise try to script. ### Why does my AI agent's seed script leave the database half-populated? A generated seed script usually inserts rows in the wrong order or misses a constraint, so it fails partway through a deep foreign-key chain. Re-running then collides with the rows already inserted, producing unique-key errors and orphaned references. The root cause is that constraint-solving across a live schema exceeds what the model reliably tracks in its working context. Seedfast takes that work off the agent entirely. One call hands it the whole request, and what comes back is a connected dataset built from the live schema rather than a script somebody has to debug. ### What is MCP and how does it help with seeding? MCP (Model Context Protocol) lets an AI agent call external tools instead of writing throwaway code. With Seedfast's MCP server, the agent calls one tool, `seedfast_run`, with a plain-English scope. Seedfast handles the schema introspection, the relationship validity, and the constraint-aware generation, and the agent returns to the feature you were actually building. ### Does Seedfast need access to my production data? No. Seedfast reads only your *schema* — table, column, and constraint definitions — not the rows in your production tables. It generates fresh data from scratch, so real production PII isn't copied into your test datasets. Your schema metadata does get sent to an AI provider (OpenAI) during generation — see [Data Handling & Privacy](/privacy-policy) for exactly what crosses the wire. ### How do I set up Seedfast with Claude Code or Cursor? Install the CLI (`npm install -g seedfast@latest`), get a free API key at [seedfa.st](https://seedfa.st), and add Seedfast to your assistant's MCP config — `.mcp.json` for Claude Code, or the MCP settings in Cursor and VS Code. Then ask the agent to seed a table in plain language. The [MCP setup guide](/docs/mcp-setup-guide) has the config for each client, and there's a start-to-finish [Claude Code walkthrough](/blog/claude-code-mcp-database-seeding) if that's your editor. ## Related Reading - [Seed Postgres from Claude Code](/blog/claude-code-mcp-database-seeding) — the start-to-finish walkthrough of the `.mcp.json` config and the one-prompt seed - [Seed Postgres from the Codex CLI](/blog/codex-cli-database-seeding) — registering Seedfast with `codex mcp add`, plus the `env_vars` route that keeps API keys out of committed config - [Seed Postgres from Google Antigravity](/blog/antigravity-mcp-database-seeding) — one `mcp_config.json` entry that both the IDE and the Antigravity CLI read, next to Google's own AlloyDB tools - [Best AI Test Data Generator](/blog/best-ai-test-data-generator) — shopping for a tool rather than a how-to? The buyer comparison of AI and synthetic generators for application testing - [MCP Setup Guide](/docs/mcp-setup-guide) — connect Seedfast to Claude Desktop, Cursor, VS Code, or Claude Code CLI - [Data Handling & Privacy](/privacy-policy) — what crosses the wire and credential hygiene - [Referential Integrity in Test Data](/blog/referential-integrity) — why foreign-key ordering is the foundation problem for multi-table generation - [Circular Foreign Key Seeding](/blog/circular-foreign-key-seed) — how to seed tables that reference each other in a cycle - [Test Data Generation Methods](/blog/test-data-generation) — the seven approaches, from handwritten SQL to schema-aware generation - [Migration Testing](/blog/migration-testing) — test `ALTER TABLE` at scale before deploying - [E2E Test Fixtures](/blog/e2e-test-fixtures) — replace brittle hand-written fixtures with generated data --- # Database Seeding: What It Is, Methods, and Best Practices > Source: https://seedfa.st/blog/database-seeding > Last updated: 2026-09-18 **Database seeding is the process of populating a database with its initial set of rows, the reference data, demo accounts and test fixtures an application expects to find in a schema the migrations have only just built.** If you've ever written an INSERT statement into a file called `seed.sql` or `seed.ts`, you've done it. The mechanism varies (a raw SQL file, an ORM seeder, a factory, a generator that reads the live schema) while the job behind it does not. Whichever one you pick, the next migration is what eventually breaks it. Starting at that file, this guide works forward through the alternatives, including [the database part of vibe coding](/blog/vibe-coding-database), where an AI tool scaffolds the whole app but leaves the seed file to you, and it ends on [Seedfast](https://seedfa.st/), which reads the live Postgres schema on every run and generates the rows from what it finds there. Anyone already past the "what is it" stage and comparing concrete tools (Laravel vs Prisma vs Drizzle vs standalone seeders) can skip ahead to the [database seeder tool comparison](/blog/database-seeder). ## Key Takeaways - Database seeding starts simple (a SQL file) and gets complicated fast as schemas grow; the problem isn't generating values, it's keeping seed data valid across migrations - Every major ORM has a built-in seeder (Prisma, Laravel, EF Core), but the data and relationships are defined manually, which means they break on schema changes just like raw SQL - Keeping foreign keys valid is what makes seeding hard at scale. Let the dependency chain run four levels deep and a hand-written insert file stops holding up, while Seedfast returns a dataset whose [referential integrity](/blog/referential-integrity) already holds - When the seed file itself becomes the bottleneck, generation is the next step. Seedfast reads the current Postgres schema on every run, so the dataset follows your migrations instead of lagging a sprint behind them ## What Is Database Seeding? Database seeding is the process of populating a database with an initial set of data. The term comes from agriculture, where nothing grows until something is planted, and in databases it means inserting a starting dataset so the application has something to work with, whether that's three admin users for local development, a thousand products for a staging demo, or a connected dataset across 40 tables for integration tests. There are two kinds of seed data, and teams routinely conflate them. Reference data lives in production and covers roles, categories, feature flags, country codes, and default settings; it's part of the application's contract and changes rarely. Development and test data is the opposite, fake users and sample orders that exist only outside production. Getting that fabricated data to cohere across columns is its own problem, and [what Faker-style generation misses](/blog/realistic-test-data) is where hand-written fixtures look plausible without holding up. The confusion starts when teams use one mechanism for both. A `seed.sql` that inserts admin roles AND a thousand fake users couples two things that change at completely different rates, the roles almost never and the fake users constantly, so every later migration has to drag both through the same increasingly brittle file. ## Why Seed a Database? A freshly migrated database has tables but no rows. Every dashboard renders an empty state, every list view returns nothing, and every test that depends on a user or a product fails before it starts. Seeding solves three concrete problems at once: - Local development gets a populated UI, so new devs can click through real flows on day one instead of staring at empty tables. - Integration and end-to-end tests get data to assert against, since a test that checks "user sees their last 10 orders" needs 10 orders in the database before it runs. - Production gets the reference rows the app needs to boot cleanly; without them the application won't start on day one. Skip it and every developer writes the same INSERT statements by hand, every CI run starts from a different database state, and onboarding degrades into "ask Marcus for a dump". ## Database Seeding vs Database Migration Migrations and seeds are often confused because they both run against the database before the application does. They are not the same thing. | | Migration | Seed | | ----------------------- | -------------------------------------------- | ---------------------------------------------- | | **What it does** | Changes structure (tables, columns, indexes) | Inserts data | | **Versioned?** | Yes — strict order | No — should be idempotent | | **Runs in production?** | Always, on every deploy | Reference data only; never test data | | **Repeatable?** | Each migration runs once | Should produce the same end state on every run | The rule of thumb is that if you'd lose work without it, it's a migration, and if you'd lose realism without it, it's a seed. Mixing the two, whether that's test data inside a migration or schema changes inside a seed, tangles deploys in ways that surface at the worst possible moment. ## Starting Simple: the seed.sql File Every database seeding journey starts here: ```sql INSERT INTO roles (id, name) VALUES (1, 'admin'), (2, 'editor'), (3, 'viewer'); INSERT INTO teams (id, name) VALUES (1, 'Engineering'), (2, 'Design'); INSERT INTO users (id, email, team_id, role_id) VALUES (1, 'alice@example.com', 1, 1), (2, 'bob@example.com', 2, 2); ``` Run it with `psql -f seed.sql` and your database has data, deterministically and under version control. For a 5-table schema that changes quarterly, this is fine. For writing a Postgres seed file from scratch, see [Seed Database: PostgreSQL Step-by-Step Guide](/blog/seed-database); for the failure modes that quietly break one, from FK ordering and idempotency to the migration that invalidates it, see [Postgres Seed Script: Build One That Lasts](/blog/postgres-seed-script). The trouble starts the day someone runs `ALTER TABLE users ADD COLUMN department_id INTEGER NOT NULL REFERENCES departments(id)`. The seed file doesn't insert departments, so the INSERT into `users` fails, and someone has to fix it, test it, and commit it. Multiply that across 30 tables and monthly migrations, and [the maintenance cost becomes its own workstream](/blog/seed-file-maintenance). ## How ORMs Handle Database Seeding Every major ORM ships a seeding mechanism. The syntax varies from one framework to the next, but the pattern underneath is identical, since you're writing code that creates records through the ORM's API. **Prisma** puts seeds in a TypeScript file: ```typescript import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const team = await prisma.team.create({ data: { name: "Engineering" }, }); await prisma.user.create({ data: { email: "alice@example.com", team: { connect: { id: team.id } }, }, }); } main() .then(async () => { await prisma.$disconnect(); }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); }); // Register the script under `prisma.seed` in package.json or prisma.config.ts, // then run: npx prisma db seed ``` **Laravel** uses seeder classes: ```php // database/seeders/DatabaseSeeder.php public function run(): void { $team = Team::create(['name' => 'Engineering']); User::create([ 'email' => 'alice@example.com', 'team_id' => $team->id, ]); } // Run: php artisan db:seed ``` **EF Core** (version 9 and later) uses `UseSeeding` and `UseAsyncSeeding` on the DbContext: ```csharp protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseSqlServer(connectionString) .UseSeeding((context, _) => { if (!context.Set().Any()) { context.Set().Add(new Team { Name = "Engineering" }); context.SaveChanges(); } }) .UseAsyncSeeding(async (context, _, ct) => { if (!await context.Set().AnyAsync(ct)) { context.Set().Add(new Team { Name = "Engineering" }); await context.SaveChangesAsync(ct); } }); } // Triggered automatically by EnsureCreated / Migrate, or manually via context.Database.EnsureCreated() ``` EF Core's older `HasData` API is now classified as "model managed data"; Microsoft recommends `UseSeeding` for general-purpose seeding because `HasData` runs as part of migrations and couples schema changes with row inserts. ORM seeders improve on raw SQL. Typed languages like TypeScript and C# catch errors at compile time, the seed code lives alongside your app, and the ORM wires direct relationships through its own API. The core problem survives, though. Data and relationships are still defined by hand, so a migration that adds a required column or changes a relation breaks the seeder [the same way a seed.sql breaks](/blog/seed-file-maintenance), only in TypeScript or PHP or C#. You're also locked to one ORM, since a Prisma seed can't be reused if half your team runs Drizzle, and going the other way hits the same wall, because `drizzle-seed` reads a Drizzle schema object rather than the database it writes to. Straddling two ORMs is what sends teams looking for a [drizzle-seed alternative that isn't tied to one ORM](/compare/drizzle-seed-alternative). For a side-by-side of 7 database seeder tools on FK resolution and schema drift, see [the database seeder tool comparison](/blog/database-seeder). ## When Seeding Stops Being Simple Most teams hit a wall around 15-20 tables, sometimes at the dependency chain, sometimes at circular references, and usually at all of them in the same week. **The dependency chain gets deep.** To insert an `order_item` you need an `order`, a `product`, and a `price`; the `order` needs a `user` and a `shipping_address`, and the `product` needs a `category` and a `vendor`. That's three or four levels of dependency for a single line item, and the seed code becomes more about wiring IDs together than about the data itself. A column-level generator does not take that work away either, because it builds from column definitions rather than from the foreign keys already in your schema, so you get an `orders.csv` and a `products.csv` and still have to match the IDs between them, which is the gap a [Mockaroo alternative that keeps foreign keys valid](/compare/mockaroo-alternative) has to close. Circular references show up next, and they're worse. A `user` carries a `manager_id` that points at another `user`, a `category` has a `parent_category_id`, and an `employee` belongs to a `department` whose own `head_employee_id` points back at an employee. Neither side can be inserted first, so a straight top-to-bottom insert fails outright, and getting it right by hand turns into fiddly, error-prone work. Deferring the constraint check to commit time is the usual way out, and [seeding a circular foreign key](/blog/circular-foreign-key-seed) works through that pattern and two others against a schema that has the loop in it. Then migrations keep breaking the seeds. Every `NOT NULL` column, every new FK constraint, every renamed table sends someone back to the file, and on a codebase with weekly migrations [seed maintenance becomes a recurring tax](/blog/seed-file-maintenance) nobody budgets for, until the file stops working and a known-good dump gets passed around instead. By now most teams either live with broken seeds, copy production (which [can carry compliance considerations](/blog/staging-without-prod-data)), or start comparing the [best Postgres test data generators](/blog/best-postgres-test-data-generator) and [best AI test data generators](/blog/best-ai-test-data-generator). When the data is meant for a sales demo, a trial sandbox, or an investor walkthrough rather than a test run, a [demo data generator](/blog/demo-data-generator) is the closer fit. Some arrive already carrying an orphaned generator, shopping for a [Snaplet Seed alternative](/compare/snaplet-seed-alternative) after the 2024 shutdown left the fork stalled, or a [Neosync alternative](/compare/neosync-alternative) after the 2025 archive, and they end up weighing the same options. ## Real Schemas, by the Numbers The 15-20 table wall isn't folklore. Seedfast has seeded 188 Postgres schemas since November 2025, and that run metadata, aggregated in August 2026, puts numbers on where real projects actually sit. - **Seedfast finds a median of 11 tables per schema, and a quarter of the schemas it seeds carry 25 or more.** Roughly a third cross the 20-table mark, and the largest reached 248. That upper quarter is squarely the territory where hand-wired dependency chains stop being worth maintaining. - **A typical seed run finishes in a few minutes**, measured over the whole run from start to completion. - Most runs ask for fixture-sized data rather than bulk loads, with typical volumes in the hundreds of rows per run and the occasional [load-test-scale](/blog/load-testing-data) exception reaching into the thousands. _The figures above cover completed seed runs from November 2025 through August 2026 and are computed from run metadata alone, with nothing read out of customer databases._ ## How Seedfast Reads the Schema Instead of Writing the Data Every failure mode above shares one shape. A human wrote the data, the schema moved, and the data went stale. [Seedfast](/docs/seed-your-database) turns that around. At the start of every run it reads your live Postgres schema, then writes rows that satisfy the constraints it found in it. Point it at the database and nothing leaves it, neither production rows nor PII. The dataset comes back built from the schema alone, connected and valid, with no relationships for anyone to wire up afterward. ```bash # Point Seedfast at your database (connection string or DATABASE_URL) seedfast connect # Generate data from the current schema seedfast seed --scope "e-commerce store with electronics products" ``` The two commands above are the whole setup once you [install the CLI](/docs/installation), and the same call happens automatically when [Claude Code drives the seeding through Seedfast's MCP server](/blog/claude-code-mcp-database-seeding) instead of a terminal prompt. Seedfast writes the rows into your PostgreSQL database itself. No CSV comes out the other end, and no IDs need matching up between files. Tell it `--scope "e-commerce store with electronics"` and you get reviews that reference real products and users whose orders make sense together, because the flag describes the domain in words and the schema supplies the structure. ![Seedfast performing database seeding on a Postgres schema: a plain-English scope approved for 16 tables and 1,530 connected records, with live per-table row counts as users, products, orders, comments, and order_items fill in with connected records.](/blog/database-seeding-1.jpg) Because Seedfast reads the database rather than your ORM schema, it works with Prisma, Drizzle, TypeORM, Laravel Eloquent, EF Core, and others, whichever one defined your **PostgreSQL** schema. When a migration changes the schema, the next `seedfast seed` picks up the new structure, usually with no seed file to update. How much of that schema a single run touches, and how many rows land in each table, is what the scope controls, and the [scoping reference](/docs/scoping) covers the flag syntax alongside the interactive equivalent. ```bash # Quick local dev setup seedfast seed --scope "small team with a few projects" # Integration test data seedfast seed --scope "3 users with 2 orders each" # Staging demo seedfast seed --scope "realistic e-commerce with 500 products and reviews" ``` For production reference data like roles and feature flags, a version-controlled SQL file is still the right tool, and the two pair well, since you run your reference seeds first and let Seedfast fill in around them. ## Database Seeding in CI/CD In a pipeline, database seeding needs to be automated, fast, and isolated. No shared databases between parallel test runs, no manual "run this script first" steps, no [fixtures drifting from the schema](/blog/e2e-test-fixtures). Python suites get the same guarantee from [pytest database fixtures](/blog/pytest-database-fixtures), scope and transactional rollback standing in for a reseed between tests. For a full pipeline setup guide, see the [CI/CD database seeding docs](/docs/cicd-database-seeding), and for the service-container and health-check mechanics underneath it, see [Seed a Postgres Test Database in GitHub Actions](/blog/github-actions-seed-postgres-database). The ideal is a single command after migrations: ```yaml # GitHub Actions steps: - name: Run migrations run: npx prisma migrate deploy - name: Seed test database env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} run: seedfast seed --scope "minimal checkout flow" - name: Run tests run: npm test ``` ORM seeders work here too, whether `npx prisma db seed`, `php artisan db:seed`, or EF Core seeding via `dotnet ef database update`, as long as the seeder script is maintained and the CI environment matches dev. Seedfast has no script to maintain in the first place, since it reads whatever schema the migration step just left behind, so the same pipeline line survives a migration that added one column and one that added ten tables. Many teams share one PostgreSQL database where each service owns its tables. There, Seedfast fills every service's tables in a single command, with none of the "seed users first, then orders, then payments" choreography. If your services use [separate databases](/blog/microservice-seeding), that's a harder problem with its own strategies. ## Database Seeding Best Practices The first rule follows straight from the two-kinds distinction above. Production reference data belongs in migrations or a dedicated reference seed that runs in every environment, while test data belongs in a separate step confined to dev, test, and staging. **Make seeds idempotent.** A seed that fails on its second run is a seed that breaks CI after a retry, so reach for `INSERT ... ON CONFLICT DO NOTHING`, upsert patterns, or a clear-and-reseed strategy. Volume should match the use case, too. [Tiny datasets hide bugs](/blog/small-data-big-lies) like broken pagination, missing indexes, and queries that only crawl at scale, so while local dev can stay small, integration tests should carry enough data to hit edge cases and [load tests need production-scale volumes](/blog/load-testing-data). The SQL patterns for producing that volume by hand, `generate_series()` and bulk `\copy` among them, live in the [PostgreSQL test data cookbook](/blog/test-data-postgresql). Copying production is the shortcut to avoid. A `pg_dump` gives realistic data but drops PII into every environment that receives it, and quality masking is harder than it sounds, since you must catalog every PII-bearing column (JSON fields, free-text notes, audit logs) and keep that catalog current as the schema evolves. [Miss one and PII leaks through](/blog/staging-without-prod-data). Generating synthetic data from the schema cuts this risk substantially, since production rows never leave production. (The generator still reads schema metadata like table and column names, so regulated teams should review what those names reveal.) **Automate, don't document.** If your onboarding doc lists four commands to run in a specific order for a working database, that's a seed script waiting to be written, and the target is one command with no decisions left to whoever runs it. ## Frequently asked questions ### What is database seeding? Database seeding populates a database with an initial dataset, either reference data for production or test data for non-production. Which method fits depends mostly on how often your schema changes. A version-controlled SQL file holds up on a schema that barely moves, while Seedfast re-reads the live Postgres schema on every run and suits one that changes weekly. ### What is db seeding? Db seeding is just shorthand for the same act. The abbreviation comes from tooling, since seed commands ship as `db:seed` in Laravel and Rails and `prisma db seed` in Prisma; the terms are otherwise identical. ### What does it mean to seed a database? You run a script against a freshly migrated schema and let its `INSERT`s execute, be it a `seed.sql`, an ORM seeder, or a generator reading the live schema. The point is turning an empty-but-valid database into one the app can run against. ### What is seeding in database? Seeding is the lifecycle step between migration and application start. Migrations build the structure and seeding supplies the contents, which is the difference between a database that's correct on paper and one a developer can click through or demo. ### What is a seed script? A seed script is the artifact that does the seeding, version-controlled and re-run when you need data, be it a `.sql` file, an ORM seeder class, or a one-command generator. A durable one is idempotent, so a second run changes nothing instead of erroring on duplicate keys. ### What does seed data mean? Seed data splits by where each kind may travel. Reference data (roles, country codes) ships everywhere including production, while development and test data (fake users, fixtures) must never reach it, which is exactly why the two belong in separate steps. ### What is the difference between migration and seeding? Both run before the app, hence the confusion, but they do opposite jobs. A migration changes structure and is versioned to run once; a seed adds rows and stays idempotent. Keep them apart, since a migration carrying test rows eventually breaks a deploy. ### Why do we seed a database? Because a valid but empty database is useless, all blank dashboards and tests that fail before they start. Seeding makes the schema usable, and a shared seed gives every developer and CI run the same starting point instead of whatever the last job left. ### Should you seed a production database? Only with reference data, the roles and default settings the app's contract depends on. Development and test data must never land there, so keep two separate steps, a small reference seed that runs everywhere including production and a larger dev/test seed confined to non-production. ### What is idempotent seeding? Idempotent seeding produces the same end state however many times it runs. The second run becomes a no-op instead of a duplicate-key crash, via `INSERT ... ON CONFLICT DO NOTHING` in PostgreSQL, `find_or_create_by!` in Rails, or `upsert` in Prisma, the minimum bar for CI where retries are routine. ### What is a seed.sql file? A `seed.sql` is the lowest-common-denominator seed, a plain SQL file of `INSERT`s you run with `psql -f seed.sql` after migrations build the tables. It has no dependencies and stays deterministic, but it's a snapshot of one schema version, so the next breaking migration invalidates it until someone edits it by hand. ### How do I seed a database in CI/CD? Add a seed step right after migrations, one command with no manual steps and no shared state across parallel runs. ORM seeders work if someone maintains the script; Seedfast skips that upkeep entirely, because `seedfast seed` reads the current schema on every run and generates against whatever the migration step just created. Host mechanics vary, so on Neon [seed the parent once and every branch inherits the data](/blog/seed-neon-database), while on [Supabase, preview branches re-apply `seed.sql` on creation](/blog/supabase-db-seed). ### Does Seedfast work with my ORM? The ORM barely matters, because Seedfast talks to the database rather than to your ORM. Prisma, Drizzle, TypeORM, Sequelize, Laravel Eloquent, EF Core, Django ORM, Rails ActiveRecord, SQLAlchemy, or raw SQL all work the same way, provided you're running PostgreSQL. Run your migrations first, then `seedfast seed`. ### When should I use a seed file vs a generator? Use a version-controlled seed file for small, stable, deterministic data like production reference rows. Reach for [Seedfast](/docs/seed-your-database) when the data has to span many tables and stay valid after the next migration, since it reads the schema fresh on every run rather than replaying rows somebody typed against last quarter's structure. For a category-level breakdown of DIY scripts, web generators, enterprise anonymization, and schema-aware generators, see [the data seeding tools comparison](/blog/data-seeding-tools); for a methods-level comparison, see [Test Data Generation: 7 Methods Compared](/blog/test-data-generation). On Postgres, the [best Postgres test data generator comparison](/blog/best-postgres-test-data-generator) ranks options on schema-awareness, FK validity, and CI fit, and you can [see how the tools stack up side by side](/compare) on the comparison hub. ### How does database seeding fit into test data management? Database seeding is one mechanism inside test data management, the act of getting initial data into a database. Test data management is the wider discipline around it, deciding where data comes from, how it stays valid as the schema moves, and who can see it. The [test data management guide](/blog/test-data-management) covers the four-tier breakdown and the in-house framework that wraps this seeding work. ## Seed your database in one command The methods on this page fail in a predictable order. Seed files break first, ORM seeders break the same way a little later, and factory code holds out longest while costing the most to keep alive. What they all share is a human encoding the schema by hand. [Seedfast](https://seedfa.st/) reads that schema itself on every run, on PostgreSQL and under whichever ORM sits on top of it, so the migration that would have broken a seed file just produces the next dataset. The [free plan](/pricing) runs on your own schema without a card, if you'd rather watch that happen than read about it. --- # Test Data Generation: 7 Ways to Fill Your Database > Source: https://seedfa.st/blog/test-data-generation > Last updated: 2026-09-18 Test data generation is the practice of creating data (synthetic, sampled, or hand-written) to populate a database for development, testing, or staging environments. Test data generation gets hard twice. First, if your team is covered by HIPAA, PCI-DSS, GDPR, or SOC 2, you can't just clone production. A masked copy is still a copy, and every environment that holds one extends your compliance perimeter. Second, even when production data is on the table, foreign keys, constraints, and cross-table dependencies mean you can't just randomize columns and hope they line up. This guide compares seven ways to fill your database with test data, from a hand-written INSERT to a schema-aware generator that reads your schema on every run, and shows which ones survive real engineering workflows. If you specifically need PostgreSQL test data with `generate_series` and `psql`-flavored examples, see [PostgreSQL Test Data: What Actually Works](/blog/test-data-postgresql); this page is the cross-stack methods comparison. > **TL;DR:** Test data generation is creating data to populate a database for development, testing, or staging. The seven main methods are handwritten SQL, online generators, Faker libraries, production copies, data masking, property-based testing, and schema-aware generation. Choose by compliance posture and schema complexity: regulated teams with 30+ tables need different tooling than small prototypes. ## Key Takeaways - Online generators (Mockaroo and similar) work for flat CSV/JSON but can't resolve foreign keys across tables - Faker libraries give you code-level control, but you wire up table dependencies by hand, then rewire them after every migration - Production copies (`pg_dump`) give perfect realism, but put PII in every environment that gets a copy, an immediate problem for HIPAA, PCI-DSS, GDPR, and SOC 2 scope - Schema-aware generators read the database instead of a config file; Seedfast re-reads it on every run, so foreign keys and constraints keep being satisfied through migrations, with no production access involved - The right method depends on compliance posture and schema complexity, not team size: regulated teams with 30+ tables need a different approach than a two-person prototype ## Test data generation methods compared | Method | Foreign keys | Maintenance | No production data | Setup | | ---------------------------- | ------------------------------------------------------------ | ---------------------------------------- | ------------------------------------ | ----------------------------------- | | Handwritten SQL | You wire it | High — update after every migration | Yes | Low | | Online generators (Mockaroo) | No — manual coordination needed | Reconfigure columns on schema changes | Yes | Medium — configure columns manually | | Faker / factories | Partial — ORM handles direct associations, you wire the rest | High — update factories after migrations | Yes | Medium | | `pg_dump` + restore | Yes — inherited from production | Re-dump periodically | **No** | Low | | Data masking | Yes — inherited from production | High — maintain field catalog + re-mask | Partial — miss a field and PII leaks | High | | Property-based | No — function inputs only | Low — tests are schema-independent | Yes | Medium | | Schema-aware (Seedfast) | Auto — resolved automatically | None — reads live schema each run | Yes — synthetic from scratch | Low | For a deeper look at producing synthetic data that replaces brittle fixtures, see [generating test data with AI](/blog/generate-test-data-with-ai); the mechanics of [synthetic test data generation](/blog/synthetic-test-data-generation), how values get invented and what makes the rows connect, have a page of their own. For the full set of tooling categories (DIY scripts, web generators, enterprise anonymization, and schema-aware generators), see [Data Seeding Tools](/blog/data-seeding-tools). ## 1. Handwritten seed files A `seed.sql` file checked into your repo with INSERT statements for every table. The classic starting point. ```sql INSERT INTO teams (id, name) VALUES (1, 'Engineering'); INSERT INTO users (id, email, team_id) VALUES (1, 'alice@example.com', 1), (2, 'bob@example.com', 1); INSERT INTO projects (id, name, owner_id) VALUES (1, 'Backend API', 1); ``` Straightforward. You know exactly what data exists. The problem starts when a migration adds `NOT NULL` columns, renames a table, or tightens a constraint, and someone has to fix the seed file by hand. It's the simplest form of test data generation, but the maintenance cost [compounds fast](/blog/seed-file-maintenance) as your schema grows. **Best for:** small schemas (under 10 tables) that rarely change. ## 2. Online generators Online generators like Mockaroo offer browser-based test data generation: define columns in a web UI and download CSV, JSON, or SQL. They're the fastest way to get a flat file of realistic-looking data. The catch: they generate one table at a time. There's no way to say "generate orders that reference valid user IDs from the users table I just created." If your schema has foreign keys (and it does), you're stitching files together manually and hoping the IDs line up. For anything with foreign keys, you'll need a tool that keeps those relationships valid. See [schema-aware generation](#7-schema-aware-generation) below. **Best for:** quick prototyping, populating a single table, or generating test CSVs that don't touch a relational database. If what you actually need is data that _looks_ real to a person clicking through a UI — a trial account, an investor walkthrough — rather than data that drives a CI assertion, see the [demo data generator](/blog/demo-data-generator) guide. ## 3. Faker and factory libraries Faker and its cousins are the developer's go-to for test data generation in code: define factories, then generate data programmatically. **Python (Faker):** ```python from faker import Faker fake = Faker() user = { "name": fake.name(), # "Jennifer Brown" "email": fake.email(), # "jbrown@example.net" "address": fake.address(), # "123 Main St, Apt 4" "created_at": fake.date_time_this_year() } ``` **JavaScript (Faker.js):** ```javascript import { faker } from "@faker-js/faker"; const product = { name: faker.commerce.productName(), // "Handcrafted Granite Chips" price: faker.commerce.price(), // "29.99" description: faker.lorem.sentence(), // lorem ipsum... }; ``` **Ruby (Factory Bot):** ```ruby FactoryBot.define do factory :user do name { Faker::Name.name } email { Faker::Internet.email } association :team end end ``` Faker gives you fine-grained control, and factory libraries like Factory Bot handle direct associations (like `:team` above) — in Python, that same factory typically drops into a [pytest fixture](/blog/pytest-database-fixtures) that hands it to the test that needs it. The tradeoff: complex multi-level dependency chains are still defined in code, and when the schema changes, the factory definitions break just like seed files. The output also tends to be nonsensical. "Handcrafted Granite Chips" doesn't help you debug a failing checkout test, and "Billion mouth support feeling prove town cold" in a `product_description` column is noise that hides real bugs instead of exposing them. If you'd rather not maintain factory definitions that break on every migration, [schema-aware generation](#7-schema-aware-generation) reads your live schema and handles the full dependency chain automatically. **Best for:** unit tests and simple integration tests where you control a small slice of the schema. ### Faker or hand-written seed data for pipeline tests? For a pipeline test, Faker is the right reach when the test needs one well-formed value in the expected format, standing alone. Hand-write the seed instead once a test leans on a specific, small, known dataset across two or three related tables; neither is built for a schema past a handful of tables. Both are code-level and person-maintained, so they carry the same migration-shaped maintenance bill, paid in a different file — a factory definition or an INSERT block. Faker earns its place by being fast and free of relational modeling, which is what you want when the column your test asserts on is all that must be right. What it can't hold together is relational consistency across tables, not the realism of any single field. Once that dataset spans enough tables that hand-wiring the relationships stops paying off, the schema-aware generation in method 7 picks up that wiring from the database itself. ## 4. Production database copies ```bash # Dump production pg_dump -h prod-host -d myapp > prod_dump.sql # Restore to staging psql -h staging-host -d myapp_staging < prod_dump.sql ``` You get the most realistic data possible, because it _is_ real data. Query plans, index usage, data distributions: everything matches production exactly. You've also just [put PII in every environment that has a copy](/blog/staging-without-prod-data). Names, emails, phone numbers, and payment history, all accessible to every developer and CI pipeline with database access. Under HIPAA, GDPR, SOC 2, or PCI-DSS, that sprawl of production data pulls every copy into your compliance scope: each environment now needs the same access controls, audit logs, retention rules, and breach-notification posture as production. Dumping and restoring a multi-gigabyte database is also slow, and you can't easily parameterize the dataset for specific test scenarios. If you need production-level schema coverage without the PII, [synthetic generation](#7-schema-aware-generation) gives you the structure and none of the liability. **Best for:** one-off performance investigations where exact production distributions matter. Not for routine test data generation. ## 5. Data masking and anonymization A production copy with sensitive fields replaced: ```sql UPDATE users SET email = 'user_' || id || '@masked.test', name = 'User ' || id, phone = '555-0' || lpad(id::text, 3, '0'); ``` This preserves real distributions and relationships, but masking is only as good as your field catalog. Miss one column (a name embedded in a `notes` JSON field, an address in a `metadata` blob, a phone number in a free-text `customer_support_log`) and unmasked PII reaches non-production environments. The source is still production, which means every refresh restarts the compliance clock and re-runs the breach-risk argument. Under GDPR Article 17, a masked staging snapshot still has to honor right-to-be-forgotten requests; under PCI-DSS, any environment containing cardholder data remains inside PCI scope whether the card numbers are masked or not. The alternative is generating data that was never real in the first place, so there are no fields to miss and no compliance clock to restart. That's the [schema-aware approach](#7-schema-aware-generation). **Best for:** regulated environments with strict distribution requirements and a dedicated data governance team, where an enterprise anonymization pipeline is already paid for. ## 6. Property-based generators Tools like Hypothesis (Python), fast-check (JS), or QuickCheck (Haskell) generate inputs satisfying declared properties: ```python from hypothesis import given from hypothesis.strategies import text, integers @given(name=text(min_size=1), age=integers(min_value=0, max_value=150)) def test_user_creation(name, age): user = create_user(name=name, age=age) assert user.name == name ``` Excellent for finding edge cases in pure functions. Scoped to individual function inputs, though, not database-level datasets. Getting a property-based generator to produce a valid, connected dataset across 50 tables means reimplementing schema awareness from scratch. **Best for:** unit tests and algorithmic edge cases. Not designed for database test data generation. ## Where AI fits among these methods The schema decides what can be inserted: tables, columns, and constraints define the valid rows. Seedfast puts a language model to work only inside those rails, on the values themselves, so a `product_description` gets an actual product description instead of lorem ipsum. That's the opposite of prompting a chatbot directly for seed SQL, which [tends to break down](/blog/generate-test-data-with-ai) once the foreign-key graph gets deep and the model runs out of working context to track every constraint at once. Through Seedfast's MCP server, an agent like Claude Code hits those same rails instead of raw-prompting a script, and [Claude Code MCP database seeding](/blog/claude-code-mcp-database-seeding) walks that path end to end. For a [healthcare](/blog/test-data-for-healthcare) or fintech schema with 30+ tables, the practical AI pattern is to let the model plan the data (semantic intent, distributions, scenario shape) while the tool holds the structural rails, so what lands satisfies the column types and constraints and every reference resolves. That's the split the schema-aware section below explains in detail. ## 7. Schema-aware generation This approach to test data generation inverts the order: instead of generating data and hoping it fits the schema, the generator reads the schema and produces data that fits by definition. [Seedfast](https://seedfa.st/) connects to your database, inspects every table, column, constraint, and foreign key, and generates a full dataset that is valid and connected, including schemas whose tables reference each other where one side of the loop is nullable or deferrable. When the schema changes, the next run picks it up, with no seed file to update. Behind that schema-reading claim sits an actual track record. Seedfast has seeded 325 distinct Postgres schemas since November 2025, and most run wider than a demo dataset would suggest. Median width across that population sits at 14 tables, the 75th percentile at 24, and the widest recorded schema reached 432 tables, while total rows generated across every run have passed 651 million so far. These are Seedfast's own numbers, measured on 2026-09-10 from its run records. ```bash # Connect once $ seedfast connect # Generate data from the current schema (example session — your numbers will differ) $ seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" → Connected to PostgreSQL → Schema inspected: tables, foreign keys, and circular dependencies resolved → Generating data... → Done. ``` One command. No production database on the other end of a connection string, no anonymization pipeline, no seed script to maintain. Seedfast reads your PostgreSQL schema and generates [contextually realistic data](/docs/data-realism): a product in the "Electronics" category gets an electronics description, not a kitchen blender, and a user with `country = "Japan"` gets a Japanese-format phone number and a culturally plausible name. The `--scope` flag accepts natural language, so you control the scenario without writing factory code: ```bash # Minimal dataset for integration tests seedfast seed --scope "3 users with 2 orders each" # Large dataset for load testing seedfast seed --scope "e-commerce store with 10000 orders across 500 users" # Targeted for a specific flow seedfast seed --scope "seed only checkout: carts, cart_items, products, users" # Regulated context — no production data involved seedfast seed --scope "healthcare portal with 50 synthetic patients, appointments, and prescriptions" ``` No seed file. No factory definitions. No maintenance when the schema changes. And because Seedfast reads the live PostgreSQL catalog directly, it works regardless of whether you use Prisma, Drizzle, TypeORM, or raw SQL migrations. The database schema is the source of truth, so the ORM doesn't enter into it. **Strengths:** no seed files to maintain, handles arbitrary schema complexity including [circular FK references](/blog/circular-foreign-key-seed), produces connected and realistic data, and generates everything from scratch. No production data is involved, so there's no PII pipeline to secure, document, or justify to auditors. Only schema structure is read; generated data is inserted into the database you point Seedfast at. See the [data handling docs](/privacy-policy) for details on what leaves your machine. **Best for:** integration tests, [E2E tests](/blog/e2e-test-fixtures), CI/CD pipelines, staging, [load testing](/blog/load-testing-data), [migration testing](/blog/migration-testing), and any team in fintech, healthcare, or another regulated industry that simply can't connect a tool to production. **[Seed your first database in under 5 minutes →](/docs/seed-your-database)** ## Why relational test data generation is the hard part A relational test data generator has to do what a column generator can't: produce rows in an order where every foreign key already points at a row that exists. The columns are the easy part. The hard part is the shape of the schema: the foreign keys form a directed graph, and a child row can only be inserted once its parent is in place. A test data generator with foreign keys to resolve either discovers that graph and inserts in the right order, or it hands you `insert or update on table violates foreign key constraint` and walks away. Three things make this the wall most generators stop at: **The dependency graph.** A schema's foreign keys describe a directed graph: `order_items` depends on `orders`, which depends on `users`, which depends on `teams`. You can't insert an order before the user it belongs to. A tool that assumes a flat list of tables, generating each one in isolation, has no way to keep those references valid. A schema-aware generator walks the foreign keys first and builds the graph before generating a single value. **Topological insert order.** Once you have the graph, the insert order is a topological sort of it: parents before children, the same shape as Kahn's algorithm. Get it right and every foreign key resolves on insert. Get it wrong and the database rejects the row. For the formal property these inserts have to preserve, see [referential integrity](/blog/referential-integrity); the practical version is just "fill the parent table before the table that points at it." **Circular foreign keys.** Sometimes the graph has a cycle: `users.manager_id` references another user, or `companies` and `employees` point at each other. A cycle has no valid topological order, so there's no "parents first" ordering to follow. There are two standard escapes: ```sql -- (a) insert with the FK NULL, then UPDATE once both rows exist INSERT INTO employees (id, name, company_id) VALUES (1, 'Ada', NULL); INSERT INTO companies (id, name, founder_id) VALUES (1, 'Acme', 1); UPDATE employees SET company_id = 1 WHERE id = 1; -- (b) DEFERRABLE constraints, checked at COMMIT instead of per-statement ALTER TABLE employees ALTER CONSTRAINT employees_company_fk DEFERRABLE INITIALLY DEFERRED; ``` This is exactly where a column-by-column tool gives up. Faker and factory libraries make you wire the chain by hand in code, then rewire it after every migration; online generators like Mockaroo reference one previously-built dataset per column through the Dataset column type, which is single-level and configured by hand. It never auto-discovers a live database's relationships or resolves a circular reference for you. Seedfast never asks you for any of that. Point it at the database and the rows arrive with every reference resolving, tables that point at each other in a cycle included, so nothing is left for you to hand-sequence. If you're choosing between Postgres-specific tools on exactly this capability, [the comparison of Postgres tools that generate test data](/blog/best-postgres-test-data-generator) puts them head to head; for the wider category of [data seeding tools](/blog/data-seeding-tools), the same question is the dividing line between a column generator and a relational one. ## What to check before you commit to one A generator that outlives the toy stage carries no hand-maintained column config. Seedfast reads the schema fresh on each run for exactly that reason, so foreign keys resolve on their own and the next migration leaves no mappings to patch. It also runs headless from a single command, which means the thing you point at your local database is the thing a CI job invokes, with no separate wiring. Scope is the third thing worth checking, since the tool that fills ten rows for a unit test should stretch to a hundred thousand for a load test without a rewrite. When you're weighing AI-based approaches specifically, the [best AI test data generator](/blog/best-ai-test-data-generator) comparison sorts them on the same traits, and once you're ready to shop across test data generation tools, you can [compare the tools side by side](/compare). ## Which method should you use? It depends on what you're testing, and on whether production data is even an option: | If you need... | Use | | -------------------------------------------------------------------------- | ---------------------------------------------------------------- | | Quick CSV/JSON for a prototype | Online generator (Mockaroo) | | Data for a unit test | Faker + your language's factory library | | A full database for integration/E2E tests | Seedfast ([schema-aware generation](#7-schema-aware-generation)) | | Realistic volumes for [load testing](/blog/load-testing-data) | Seedfast or production copy | | A staging environment [without PII](/blog/staging-without-prod-data) | Seedfast ([synthetic generation](#7-schema-aware-generation)) | | To [validate a migration](/blog/migration-testing) against real-sized data | Seedfast with a large `--scope` | | Data for a fintech or healthcare app that can't touch production | Seedfast ([schema-aware generation](#7-schema-aware-generation)) | | A fundamentals primer on how seeding actually works | [The database seeding guide](/blog/database-seeding) | | A side-by-side comparison of seeder tools and per-ORM syntax | [Database seeder tools compared](/blog/database-seeder) | The pattern is consistent: as schemas get bigger and testing gets more serious, you need something that understands foreign keys, survives migrations, and doesn't require a seed file that someone has to maintain. Online generators and Faker work great at the bottom of the testing pyramid. They break down when you need a connected, multi-table dataset at the top. If your team maintains [separate fixture systems](/blog/seed-file-maintenance) for unit tests, integration tests, E2E, and staging, that's four test data generation pipelines for the same schema. Small datasets also hide entire categories of bugs that [only surface at realistic volumes](/blog/small-data-big-lies). A single generator that adapts by scope is simpler. And once you are choosing deliberately across environments — who provisions the data, how it survives migrations, who may see it — you have crossed from picking a generator into [test data management](/blog/test-data-management), which is worth reading about as its own discipline before the choice hardens into habit. ## Test data generation in CI/CD In a pipeline, your seeding approach needs to be automated, fast, and isolated: no shared state between parallel runs, no manual steps, and no [fixtures drifting from the schema](/blog/e2e-test-fixtures). ```yaml # GitHub Actions example - name: Seed test database run: seedfast seed --scope "minimal checkout flow" ``` If your CI data setup is more than two lines, that's maintenance surface. Every extra step is a potential red build that has nothing to do with your application code. For [microservice architectures](/blog/microservice-seeding) where multiple databases reference each other, schema-aware generators can seed them with cross-service IDs staying consistent, without coordinating seed scripts across repos. [See the full CI/CD setup guide →](/docs/cicd-database-seeding) ## Test data generation and compliance Production copies in non-production environments create compliance exposure under GDPR, HIPAA, SOC 2, and PCI-DSS. Each framework treats the data the same way regardless of which environment it lives in: if developer laptops, staging servers, or CI runners hold real PII, they inherit the same access controls, audit trails, retention rules, and breach-notification obligations as production. Data masking narrows that scope but doesn't eliminate it. The masking pipeline still processes production records, the output still carries residual re-identification risk, and every missed column (a name in a JSON blob, a phone number in a support log) reopens the audit question. Seedfast sidesteps the question by never reading a production row. Working from the schema rather than from the data means nothing leaves production, because nothing was taken from it in the first place. A [staging environment filled that way](/blog/staging-without-prod-data) holds no personal data to scope, which is a consequence of generating rather than copying and not a compliance verdict on your environment. See the [data handling documentation](/privacy-policy) for the details of what leaves your machine and what doesn't. ## Frequently asked questions ### How do you generate test data for a database? Connect a generator to your database and let it read the schema. [Seedfast](https://seedfa.st/) inspects tables, columns, foreign keys, and constraints, then generates valid, connected INSERT statements, with no seed files to write or maintain. For a detailed comparison of approaches, see [data seeding tools](/blog/data-seeding-tools). ### What is test data generation? Test data generation is any process that creates data for software testing. It ranges from hand-written INSERT statements to schema-aware tools that read your database and produce thousands of valid, connected rows automatically. The goal is realistic data that surfaces real bugs without using production records. ### What are the best test data generation tools? The best test data generation tools depend on the scope you need to cover. For flat files, most online generators will do the job; at the code level, Faker and Factory Bot build objects inside your test suite. Full relational seeding across a real schema is where Seedfast comes in, reading your PostgreSQL foreign keys and generating every table in one command. ### How do I generate test data with foreign keys? Use a tool that reads the schema, not one that generates columns in isolation. Foreign keys define an ordering: child rows can only be inserted after their parents exist, and tables with circular references need to be inserted in a single transaction with deferred constraints. Faker and Mockaroo leave that work to you. Seedfast reads those relationships from the database itself, so the rows it writes come out with every reference resolving, two tables that point at each other included. ### How much test data do I need for tests? Enough that realistic queries hit realistic code paths. A handful of rows is fine for unit tests and fast feedback loops, but integration and end-to-end suites need volumes close to what production looks like. Otherwise you miss the class of bugs that [only appear at scale](/blog/small-data-big-lies): TOAST decompression, stale query planner statistics, OFFSET pagination cliffs. Seedfast takes the row count from the same plain-English scope, so one description runs at 100, at 10,000, or at a million rows with only the number changed. ## Try it on your schema Seedfast handles test data generation for your PostgreSQL schema: connect once, and it generates a valid, connected dataset in one command. No seed files, no factories, no PII pipeline to secure, and no production database to connect to. **[Connect Seedfast to your schema →](/docs/seed-your-database)** Installs via Homebrew or npm. Free plan, no credit card required. --- # How to Fill 5 Databases That Reference Each Other > Source: https://seedfa.st/blog/microservice-seeding > Last updated: 2026-07-22 _Where the monolith needed one seed script, your microservices spread across five databases held together by three implicit ID contracts and whatever order someone runs the seeds in._ **Microservice database seeding is more than running your seed script five times.** You split the monolith into independent services, each team owning its schema and migrations and choosing its own deploy cadence, and on paper the architecture looks clean. Then someone on the team tries to set up a local development environment. They spin up the user service, seed its database, and start the order service, which immediately throws `ERROR: relation "users" does not exist`. Of course it does, since that table lives in a different database now. So they seed the order service database too, only to find its orders reference user IDs absent from the user service. The payment service expects order IDs from a different seed run, and the notification service looks up preferences for a user ID that maps to nothing. You end up with five databases that each work in isolation while none of them agree with one another, which is one of the most underestimated costs of distributed architecture. --- **One note before we dig in.** If your "microservices" share a single PostgreSQL instance and just own different tables, this article is overkill, since [Seedfast](https://seedfa.st/) reads that one schema and fills every table in it (see [the database seeding guide](/blog/database-seeding), or the [per-ORM database seeder reference](/blog/database-seeder) if you're choosing between Prisma, Drizzle, or TypeORM seeders). The strategies here are for teams with **genuinely separate databases per service**, where nothing auto-coordinates IDs across service boundaries. --- ## The Monolith Had It Easy In a monolith, seeding is straightforward, and a single `seed.sql` loads one database in one transaction: ```SQL -- seed.sql: everything in one place INSERT INTO users (id, name, email) VALUES (1, 'Alice Chen', 'alice@example.com'), (2, 'Bob Martinez', 'bob@example.com'); INSERT INTO products (id, name, price) VALUES (101, 'Widget Pro', 29.99), (102, 'Widget Lite', 9.99); INSERT INTO orders (id, user_id, product_id, status) VALUES (1001, 1, 101, 'completed'), (1002, 2, 102, 'pending'); INSERT INTO payments (id, order_id, amount, status) VALUES (5001, 1001, 29.99, 'captured'), (5002, 1002, 9.99, 'authorized'); INSERT INTO notifications (id, user_id, type, message) VALUES (9001, 1, 'order_shipped', 'Your Widget Pro has shipped'), (9002, 2, 'payment_pending', 'Complete your payment for Widget Lite'); ``` Foreign keys enforce consistency. One `psql` command, and the entire application has coherent data; every join works and every API response makes sense. Now distribute that across five services: ```bash ┌──────────────┐ ┌─────────────────┐ ┌───────────────┐ │ User Service │ │ Product Service │ │ Order Service │ │ users_db │ │ products_db │ │ orders_db │ │ │ │ │ │ │ │ users │ │ products │ │ orders │ │ preferences │ │ categories │ │ order_items │ └──────┴───────┘ └────────┴────────┘ └───────┴───────┘ │ │ │ │ ┌────────┴────────┐ │ └────────►│ Payment Service │◄─────────┘ │ payments_db │ │ │ │ payments │ │ refunds │ └────────┴────────┘ │ ┌────────▼───────┐ │ Notification │ │ Service │ │ notifs_db │ │ │ │ notifications │ │ templates │ └────────────────┘ ``` There are no foreign keys between these databases. The order service stores a `user_id` column, but nothing enforces that the user actually exists. Over in the payment service, an `order_id` sits in a column with no constraint linking it back to the orders database. These are **implicit references**, contracts that exist in application code, not in database schemas. Seed one database without the others, and you get orphan records. Seeding them all independently leaves the IDs mismatched, while a wrong order breaks application logic in ways that look like bugs but are actually data inconsistency. ## The Cross-Service Reference Problem Microservice databases reference each other through several patterns, and each one creates a seeding challenge. ### Shared User IDs Almost every service stores a user ID. The user service is the source of truth, but every other service has a user_id column pointing back to it. Seed the order service with user IDs 1-100, and the user service with user IDs 500-600, and every order belongs to a nonexistent user. ```bash orders_db.orders.user_id = 42 -> users_db.users.id = ??? payments_db.payments.user_id = 42 -> users_db.users.id = ??? notifs_db.notifications.user_id = 42 -> users_db.users.id = ??? ``` ### Service-to-Service ID Contracts The payment service references not just users but orders too, the notification service references both users and orders, and some services reference products by ID while others use SKU. These implicit contracts form a dependency graph that's invisible to any single service's schema: ```bash notification_service.notifications: - user_id -> user_service.users.id - order_id -> order_service.orders.id - product_sku -> product_service.products.sku payment_service.payments: - user_id -> user_service.users.id - order_id -> order_service.orders.id order_service.orders: - user_id -> user_service.users.id - product_id -> product_service.products.id ``` Nothing in any single service's schema encodes those arrows, so the cross-service half of the job stays yours to describe. The within-service half does not. Run Seedfast against one service's database and it reads that service's own schema, filling its tables with rows whose local foreign keys already resolve. ## UUID vs Integer IDs: Cross-Service Coordination Before choosing a seeding strategy, choose an ID type, which shapes every seed scope you write for the rest of the system. With **integer IDs**, seed scopes can reference ranges: `"orders for user IDs 1–1000"`. Ranges are human-readable, auditable, and stable across runs as long as the seed order is deterministic. The downside is that services must avoid overlapping ranges, and the moment one service switches to UUIDs, every downstream seed file breaks. With **UUIDs**, cross-service collision is impossible by design, but scopes can't be expressed as ranges. The pattern instead seeds the service that owns the ID first, queries the actual values back, then passes them explicitly to the next scope. For the handful of records a test pins by literal value, [the UUID v7 generator](/tools/uuid-v7-generator) writes a batch of keys you can paste into both services, already ordered by the timestamp v7 carries in its leading bits. ```bash # Seed the user service first SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \ --scope "50 users with profiles and addresses" # Query the IDs back — works identically for integer or UUID keys USER_IDS=$(psql "$USERS_DB_URL" -t -A -c \ "SELECT id FROM users ORDER BY created_at DESC LIMIT 50" | paste -sd,) # Pass them explicitly to the next service's scope SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \ --scope "200 orders for users with IDs: $USER_IDS" ``` If you're building a new system and can choose, **UUID v7** is the pragmatic default, collision-safe like UUID v4 but time-ordered so range-style reasoning still works. The cross-service ID contracts doc your team maintains should record the ID type for every entity, since it's the schema contract every seed scope depends on. First time seeing `SEEDFAST_DSN`? It's the Postgres connection string Seedfast's CLI reads from the environment, falling back to `DATABASE_URL` if unset. A dedicated variable keeps Seedfast from clashing with other CI tools that also read `DATABASE_URL`. ## Event-Sourced State and Why Direct Seeding Lies If your services use direct REST or gRPC calls rather than events, skim this section, since the warnings apply only to event-driven architectures. Some architectures use event sourcing. The order service doesn't call the payment service directly. It emits an `OrderCreated` event, and the payment service builds its state from that stream. Seeding the payment database directly, without replaying events, produces state that could never exist. ```bash # What actually happens in production: OrderCreated { order_id: 1001, user_id: 42, total: 29.99 } -> PaymentService creates payment { order_id: 1001, amount: 29.99, status: "pending" } -> NotificationService sends "Order received" to user 42 # What happens when you seed databases independently: payments_db has payment { order_id: 9999 } -- order 9999 doesn't exist notifs_db has notification for user 7777 -- user 7777 doesn't exist ``` Inserting straight into the read model leaves the projection version wrong and downstream replays diverging, so the first production-shaped test that reads the event log can't reconstruct the aggregate correctly. For Postgres-based event stores, the fix is to seed the event log directly and let the projection rebuild: ```bash # Seed the event log, not the read model SEEDFAST_DSN="$EVENTS_DB_URL" seedfast seed \ --scope "1000 domain events: OrderCreated, PaymentCaptured, OrderShipped, with realistic aggregate_id grouping and monotonic timestamps" # Replay events into the read model via your service's projection worker. # The exact command is service-specific — replace with whatever rebuilds # your projections (a rake task, a Go CLI subcommand, etc.): docker compose run --rm order-service project-events --from-genesis ``` For Kafka-backed stores the same idea applies, but injection happens through a producer script (`kafka-console-producer` or equivalent); seed the event stream, and let the projection build the read model. Direct read-model seeding is acceptable only when the read model is rebuilt on every restart (test containers with `--rm`) and you never need to inspect event history. ## Why Microservice Database Seeding Breaks in Practice When confronted with cross-service seeding, teams typically reach for one of these approaches, each of which looks reasonable at first and then breaks. ### 1. "Just Seed Service A First, Then B, Then C" The ordering approach seeds users first, then products, then orders, then payments, then notifications, each service after the ones it depends on. ```bash #!/bin/bash # seed_all.sh -- the script that someone wrote at 2 AM echo "Seeding user service..." psql $USERS_DB < seeds/users.sql echo "Seeding product service..." psql $PRODUCTS_DB < seeds/products.sql echo "Seeding order service..." psql $ORDERS_DB < seeds/orders.sql echo "Seeding payment service..." psql $PAYMENTS_DB < seeds/payments.sql echo "Seeding notification service..." psql $NOTIFS_DB < seeds/notifications.sql ``` It works until something shifts underneath it. The product team swaps sequential integer IDs for UUIDs, the user service adds a required `tenant_id` column, or a new service appears that nobody adds to the script. The seed files reference each other by hardcoded IDs, so any change to one file requires updating all downstream files. Maintenance cost grows with the square of the number of services. ### 2. Copying Production Data Subsets "Let's just dump 1% of production data from each service." ```bash # "Clever" approach: dump a consistent slice psql "$USERS_DB_URL" -c "\copy (SELECT * FROM users WHERE id < 1000) TO 'users_slice.sql'" psql "$ORDERS_DB_URL" -c "\copy (SELECT * FROM orders WHERE user_id < 1000) TO 'orders_slice.sql'" # The third query needs a cross-database join — already a smell: psql "$PAYMENTS_DB_URL" -c "\copy (SELECT * FROM payments WHERE order_id IN (...)) TO 'payments_slice.sql'" ``` You now have real production data sitting in every developer's laptop. GDPR and SOC 2 both restrict where personal data may reside, and developer laptops routinely fail those checks. The subsets are also nearly impossible to keep consistent, since a slice of users from the user service forces a matching slice of their orders, payments, and notifications, a cross-database join across five databases. Someone writes a script that approximates this, and it works 90% of the time. The other 10% produces orphan references that cause subtle, intermittent test failures. For the full rationale on avoiding this pattern, see [staging without production data](/blog/staging-without-prod-data). ### 3. Shared Test Fixtures in a Repo A single repository holds JSON or SQL fixtures that every service reads: ```bash test-fixtures/ users.json # { "users": [{ "id": 1, ... }, ...] } products.json orders.json # references user IDs from users.json payments.json # references order IDs from orders.json ``` This works for small datasets with stable schemas, but it carries the same coupling problem as the ordered script, so changing the users fixture requires updating every fixture that references user IDs. It also forces every service to depend on a shared repository, undermining the independence microservices are supposed to provide. And the fixtures are always tiny, holding maybe 10 users and 20 orders. Nobody maintains a fixture set with 50,000 users and realistic distributions across five services. ## What Actually Works No single tool solves cross-service seeding, but a few strategies hold up better than the anti-patterns above. ### Strategy 1: Dependency-Ordered Seeding with Shared ID Ranges Define explicit ID ranges every service agrees on, then seed in dependency order using those ranges. ``` # seed-config.yaml -- shared convention id_ranges: users: 1-10000 products: 100001-110000 orders: 200001-300000 payments: 400001-500000 seeding_order: - user_service # no dependencies - product_service # no dependencies - order_service # depends on users, products - payment_service # depends on orders, users - notification_service # depends on users, orders ``` This works if you enforce the convention and every team respects it. The cost is rigidity, since the ID ranges are arbitrary constraints that don't exist in production and can mask bugs related to ID collision or generation strategy. ### Strategy 2: API-Driven Seeding Skip direct database inserts and use each service's API to create data, chaining the returned user IDs into order creation and the returned order IDs into payments. ```python # seed_via_apis.py import requests # Create users users = [] for i in range(100): resp = requests.post("http://user-service/api/users", json={ "name": f"Test User {i}", "email": f"user{i}@test.com" }) users.append(resp.json()) # Create orders using real user IDs orders = [] for user in users[:50]: resp = requests.post("http://order-service/api/orders", json={ "user_id": user["id"], # real ID from user service "product_id": "prod-101", "quantity": 2 }) orders.append(resp.json()) ``` The approach guarantees consistency, since you're using the actual IDs each service generates, and it triggers events so downstream services (payments, notifications) get their data through the normal event flow. The downside is that it's slow. Creating 10,000 orders through an API that creates them one at a time takes minutes, and 100,000 takes far too long. You're also limited by API capabilities, and if there's no bulk creation endpoint, you're making N HTTP requests. And if any service is down during seeding, the entire chain breaks. ### Strategy 3: Contract-Based Seeding with Scope Instead of hardcoding IDs or chaining API calls, you describe what the data should look like, and Seedfast reads each service's schema and fills it with valid, connected rows. For each service database, you seed with a scope that describes its role in the broader system: ```bash # Seed user service -- the root of the dependency graph SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \ --scope "1,000 users with varied profiles, addresses, and preferences" # Seed product service -- independent root SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed \ --scope "200 products across 10 categories with pricing tiers" # Seed order service -- references users and products SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \ --scope "5,000 orders referencing user IDs 1-1000 and product IDs 1-200, with realistic status distribution across the last 6 months" # Seed payment service -- references orders and users SEEDFAST_DSN="$PAYMENTS_DB_URL" seedfast seed \ --scope "payments for orders with IDs matching the order service, mix of completed, pending, and refunded statuses" # Seed notification service -- references everything SEEDFAST_DSN="$NOTIFS_DB_URL" seedfast seed \ --scope "notifications for users 1-1000, referencing recent orders, including order confirmations, shipping updates, and payment receipts" ``` Each database is seeded independently, yet the scope description creates implicit coordination, so user IDs in the order service line up with the user service, and order IDs in the payment service line up with the order service. Within each database Seedfast handles referential integrity, while the cross-service consistency comes from your scope descriptions. It isn't magic, and you still need to think about which ID ranges overlap. But the maintenance burden drops dramatically; when the user service adds a new column, you don't need to update five fixture files. Re-run the same scope description, and Seedfast adapts to the updated schema. The scope examples below use integer ranges for readability. For UUID-based systems, compose this with the query-back pattern from the UUID section above. ## A Practical Example: E-Commerce Platform Let's walk through seeding a complete e-commerce platform with five services, a topology many teams operate. **The Services** - **Users** (users_db): users, addresses, preferences. No dependencies (root) - **Products** (products_db): products, categories, inventory. No dependencies (root) - **Orders** (orders_db): orders, order_items. References user_id, product_id - **Payments** (payments_db): payments, refunds. References user_id, order_id - **Notifications** (notifs_db): notifications, templates. References user_id, order_id ### The Dependency Graph ```text users_service ──┐ ├──► order_service ──┬──► payment_service ──┐ product_service ──┘ │ │ └────────────────────────┴──► notification_service ``` ### Seeding Script ```bash #!/bin/bash set -euo pipefail # Phase 1: Seed root services (no cross-service dependencies) echo "Phase 1: Seeding root services..." SEEDFAST_DSN="$USERS_DB_URL" seedfast seed \ --scope "1,000 users with names, emails, phone numbers, billing and shipping addresses, and notification preferences" \ --output plain & SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed \ --scope "500 products across 15 categories including electronics, clothing, and home goods, with prices ranging from 5 to 500 dollars, and inventory counts" \ --output plain & wait echo "Phase 1 complete." # Phase 2: Seed services that depend on roots echo "Phase 2: Seeding order service..." SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \ --scope "8,000 orders for user IDs 1-1000 referencing product IDs 1-500, with 1-5 items per order, status distribution of 60% completed 25% shipped 10% processing 5% cancelled, spread across the last 12 months" \ --output plain echo "Phase 2 complete." # Phase 3: Seed services that depend on orders echo "Phase 3: Seeding downstream services..." SEEDFAST_DSN="$PAYMENTS_DB_URL" seedfast seed \ --scope "payments for order IDs 1-8000, with amounts matching order totals, 90% captured 5% authorized 3% refunded 2% failed" \ --output plain & SEEDFAST_DSN="$NOTIFS_DB_URL" seedfast seed \ --scope "notifications for user IDs 1-1000 about order IDs 1-8000, including order confirmation, shipping update, and delivery confirmation types, with timestamps after the corresponding order dates" \ --output plain & wait echo "Phase 3 complete. All services seeded." ``` Because each phase runs its services in parallel, total wall-clock time is roughly the slowest service in each phase, not the sum of all five. > **See it run on your setup.** Seedfast's phased seeding works against any Postgres-compatible database — Supabase, RDS, Neon, self-hosted. [Try it with the getting-started guide](/docs/seed-your-database). ## Verifying Cross-Service Consistency After Seeding A successful seed run doesn't always mean the data is consistent across separate databases. Seedfast exits with an error on constraint violations within a single database, but cross-service references are invisible to it, and the order service can successfully insert 8,000 orders referencing users 1–1000, while the user service ended up with only 800 rows because a scope quirk hit a unique constraint at row 801. A one-page verification script catches this before your tests run: ```bash #!/bin/bash # verify-seed-consistency.sh -- runs after all phases complete set -euo pipefail # Dump IDs from each service to temp files psql "$USERS_DB_URL" -t -A -c "SELECT id FROM users" | sort -u > /tmp/users.ids psql "$ORDERS_DB_URL" -t -A -c "SELECT user_id FROM orders" | sort -u > /tmp/order-users.ids # Any user_id in orders that doesn't exist in the user service? ORPHANS=$(comm -23 /tmp/order-users.ids /tmp/users.ids | wc -l) if [ "$ORPHANS" -gt 0 ]; then echo "FAIL: $ORPHANS orders reference non-existent users" comm -23 /tmp/order-users.ids /tmp/users.ids | head -5 exit 1 fi echo "OK: orders ↔ users cross-service references are consistent" ``` The check uses only standard `psql` and shell tools, with no `dblink`, no extensions, no superuser. It works on Supabase, RDS, Neon, and anywhere else your databases live. Add a block per cross-service reference you care about (payments ↔ orders, notifications ↔ users, etc.) and the whole check runs in two or three seconds. The script assumes a POSIX shell; on Windows, run it in WSL or Git Bash. The alternative is losing hours to a phantom 404 when an integration test can't find an entity that "definitely should exist", though a few seconds of verification here saves that hunt. ## Wiring This Into Your Dev and CI Workflows Plugging phased seeding into Docker Compose or CI/CD is mechanical, taking one environment variable per database, seeding after migrations, and parallelizing within phases. For the full walkthrough (ephemeral Postgres services in GitHub Actions, `SEEDFAST_DSN` per service, exit-code handling, Docker Compose templates), see [CI/CD database seeding](/docs/cicd-database-seeding). ## Lessons From Teams Who've Done This A few patterns consistently emerge from teams running 5-20 microservices. ### Document Your Cross-Service ID Contracts Most teams discover their implicit ID dependencies when seeding breaks. Document them explicitly: ```bash # Cross-Service Data Contracts ## user_id - Source: user-service (users.id, auto-increment) - Referenced by: order-service, payment-service, notification-service - Format: integer, 1-based ## order_id - Source: order-service (orders.id, UUID v4) - Referenced by: payment-service, notification-service - Format: UUID ## product_id - Source: product-service (products.id, auto-increment) - Referenced by: order-service (order_items.product_id) - Format: integer, 1-based ``` That document becomes the source of truth for seeding, testing, and debugging. It also helps new developers understand how data flows across services, which is notoriously opaque in microservice architectures. ### Seed Fewer Services Than You Think Not every E2E test needs all five databases seeded. If you're testing the checkout flow, you need users, products, and orders. You don't need the notification service's database, which can tolerate missing user profiles gracefully. Seed only the services that your test scenario actually exercises, the same minimalism behind scoped [E2E test fixtures](/blog/e2e-test-fixtures). ```bash # Checkout flow test: only seed what the checkout touches SEEDFAST_DSN="$USERS_DB_URL" seedfast seed --scope "10 users with addresses" SEEDFAST_DSN="$PRODUCTS_DB_URL" seedfast seed --scope "20 products with inventory" # orders and payments will be created by the test itself ``` ### Accept That Some Inconsistency Is OK In production, microservice databases are eventually consistent, with windows where the order service holds an order the payment service hasn't processed yet, and your test data can reflect that. Not every cross-service reference needs to be perfect, because some tests specifically need to verify how services handle missing references. ```bash # Deliberately seed some orphan references to test error handling SEEDFAST_DSN="$ORDERS_DB_URL" seedfast seed \ --scope "100 orders, 10% with user IDs that don't exist in the user service, to test the order service's graceful degradation" ``` Designing for this on purpose means your services should tolerate missing cross-service data, and the tests need to confirm they actually do. ### What no tool fixes for you Microservice data seeding is genuinely hard. There's no tool — including Seedfast — that makes it trivial. The fundamental challenge is that microservices trade data consistency for operational independence, and seeding is where that tradeoff becomes obvious. What you can do is reduce the manual coordination. Define your cross-service contracts, seed in dependency order, and lean on scope descriptions to keep seed runs loosely coupled, all while accepting that perfect consistency across five independently-seeded databases takes deliberate effort. The monolith's `seed.sql` stayed simple only because the monolith itself was. Its microservice equivalent is a phased seeding pipeline with explicit ID contracts, more complex but manageable if you treat it as a first-class engineering problem from the start. ## Frequently asked questions ### What order should I seed microservice databases? Seed services with no cross-service dependencies first (typically users and products), then services that reference those IDs (orders), then downstream services (payments, notifications). The phased script above parallelizes each phase to cut total wall-clock time. Seedfast handles whatever sits inside each of those databases, so the only sequencing you own is the one between services. ### How do I keep user IDs consistent across five databases? Either define explicit ID ranges per service in a shared config (users 1–10,000; orders 200,001–300,000) and honor them in every seed scope, or use query-back seeding where you seed the user service first and pass the returned IDs into downstream scopes. ### Should I use UUIDs or integer IDs for cross-service references? UUIDs eliminate collision risk when services generate IDs independently, but they make seed scopes harder to write as ranges. If your services already use integers, keep them and coordinate ranges. If you're starting fresh, UUID v7 gives you collision safety plus time-ordering. ### Can I just copy a slice of production data across all five databases? You can't do it safely. Consistent cross-database subsets require joins across separate hosts, which is nearly impossible to keep referentially clean, and you put real PII on every developer's laptop. Point Seedfast at each service in turn and the rows for that service come out of its own schema, so nothing real has to cross a host boundary at all. ### What happens if I seed an event-sourced service by inserting directly into the read model? You produce state that could never exist in production, aggregates inconsistent with the event log. The correct approach is to seed the event stream and let the projection rebuild from it. ### How small should seed volumes be in CI vs local dev? CI seeds should finish in under 30 seconds, typically 10× smaller than local dev. The phased script here uses 1,000 users and 8,000 orders locally; in CI, drop the same scopes to roughly 100 users and 500 orders, changing only the counts. See also [large-volume seeding](/docs/large-volume-seeding) for tuning Postgres itself when you do need big runs. ## Seed five databases that actually agree with each other [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) _Seedfast seeds each database independently while respecting the cross-service relationships you describe, so there are no shared fixtures or coordination scripts to maintain, and production data never leaves production._ --- # E2E Tests Without Brittle Fixtures: Generate Data on the Fly > Source: https://seedfa.st/blog/e2e-test-fixtures > Last updated: 2026-07-22 *Flaky Playwright and Cypress runs trace back, more often than anyone expects, to e2e test fixtures that quietly stopped matching the schema after last Tuesday’s migration.* You have probably watched a Cypress test that passed for months fail on a Monday, though nobody touched the test or the feature. The cause is almost always the data — a column landed on `users` over the weekend while `cypress/fixtures/users.json` kept the old shape, the shared test database got wiped, or the hardcoded user `42` an assertion needs was deleted by a parallel test. Failures like these stay quiet, so the cost is easy to miss. It surfaces as one flaky test this week and two the next, until someone loses half a day “fixing” tests that were never wrong. ## Key Takeaways - **Schema drift is the hidden cause behind most fixture failures.** Every migration behind a checked-in fixture adds maintenance nobody scheduled, and the failure surfaces in the test runner while the [small-data bugs that only real data catches](/blog/small-data-big-lies) stay hidden underneath. - **The five anti-patterns share one habit, treating test data as shared, mutable state.** Shared databases, hardcoded IDs, checked-in fixtures, test-order dependencies, and restored dumps each block isolation, and without it you can’t run tests in parallel. - **Generate the data from the live schema instead of storing a copy of it.** Seedfast picks up a migration's new column on the very next run, so nobody opens a fixture-update PR to catch the suite back up. - **Query for the shape of the data a test needs rather than a hardcoded ID.** Asking for a user who has orders is a mechanical rewrite, and it’s what makes generated data usable. - **[Seedfast](https://seedfa.st/) turns the schema itself into the test-data source.** Wired in as a setup step, it seeds a fresh database on every E2E run from the live structure (tables, constraints, foreign keys), so any local or CI database works as a target. ## The Fixture Maintenance Trap The first fixture you write always looks completely harmless. You need a single user to exercise the login flow, so you create `test-user.json`: ```json { "id": 42, "email": "testuser@example.com", "name": "Test User", "role": "admin" } ``` Then the `users` table gets a `department_id` foreign key the fixture knows nothing about. Its seeded row fails to insert, or comes in with a `NULL` the code never expected, and the E2E test blows up with a cryptic error far from the real cause. So someone adds `department_id`. But a department has to exist first, so `departments.json` appears, and the pull repeats for every related table, until six months later you have 30 fixture files in a dependency graph nobody fully understands. ```bash cypress/fixtures/ departments.json users.json # depends on departments categories.json products.json # depends on categories orders.json # depends on users order_items.json # depends on orders AND products payments.json # depends on orders shipping.json # depends on orders AND users reviews.json # depends on users AND products ``` Every schema migration is now a fixture migration too. As the ORM models and API contracts change, a JSON file somewhere drifts out of sync, and the tests fail only later, on a fresh database, in a new CI container, or when stale data meets new code. ## Five Anti-Patterns That Make It Worse ### 1. The Shared Test Database The whole QA team points at one database. Tests pass locally because the data happens to be there, CI fails because someone truncated the table, and two developers running it at once step on each other’s rows, none of it reproducible when the state differs per machine. ```SQL Developer A: INSERT INTO users (id, email) VALUES (1, 'alice@test.com') Developer B: INSERT INTO users (id, email) VALUES (1, 'bob@test.com') -- ERROR: duplicate key value violates unique constraint "users_pkey" ``` The code behind that message is [23505](/tools/postgres-error-code-lookup#23505), which is what a unique constraint returns, whichever of the two developers lost the race. The usual fix, random suffixes, only pushes the mess into the assertions. ### 2. Hardcoded IDs and Magic Values ```javascript // Cypress test cy.visit('/users/42/orders') cy.get('[data-testid="order-row"]').should('have.length', 3) ``` This test assumes user 42 exists and has exactly 3 orders, so it breaks the moment the seed data shifts, somebody cleans the database, or CI starts auto-increment from a different number. The failure reads “expected 3, got 0” and explains nothing. ### 3. Fixture Files Checked Into Git ```json // fixtures/orders.json -- last updated 4 months ago [ { "id": 1, "user_id": 42, "total": 99.99, "status": "completed" }, { "id": 2, "user_id": 42, "total": 149.50, "status": "pending" }, { "id": 3, "user_id": 42, "total": 29.99, "status": "completed" } ] ``` Four months ago the `orders` table had no `currency` column, no `shipping_address_id` foreign key, and no `NOT NULL` on `created_at`, and the fixture still doesn’t know. It inserts silently with defaults until one is missing or a constraint rejects the row, leaving the code to assume a field never provided. ### 4. Test-Order Dependencies ```javascript describe('Order flow', () => { it('creates a user', () => { // Creates user, stores ID in Cypress alias cy.request('POST', '/api/users', { name: 'Test' }).as('user') }) it('creates an order for the user', () => { // Depends on the previous test having run successfully cy.get('@user').then(user => { cy.request('POST', '/api/orders', { user_id: user.id }) }) }) it('verifies the order appears in the list', () => { // Depends on BOTH previous tests cy.visit('/orders') cy.get('[data-testid="order-row"]').should('exist') }) }) ``` If “creates a user” fails or is skipped, every later test fails too. Nothing runs in parallel or in isolation, because the suite is a chain and one broken link takes down everything after it. ### 5. The “Just Restore a Dump” Approach ```yaml # CI pipeline - name: Restore test database run: pg_restore --clean --no-owner -d testdb fixtures/test_dump.sql ``` Database dumps are the heavyweight version of fixture files, large and usually binary, painful to diff, and just as quick to drift. They carry a developer’s machine assumptions about sequences and PostgreSQL versions, and a dump from production inherits every privacy and compliance liability of [running real production data through staging](/blog/staging-without-prod-data). ## The Test Isolation Problem The same mistake sits under all five — data any test can read, write, or delete without the others knowing. Share data and isolation goes first, taking parallel execution with it, and an 8-minute E2E suite stretches to 45. Developers then stop running it locally, so bugs a local run would have caught slip into CI, and fixes that took minutes now take hours. The dependency chain: ```bash Shared fixtures -> Tests depend on each other's data -> No parallel execution -> Slow test suite -> Developers skip it -> Bugs in CI -> Slow feedback loops ``` Every serious testing framework recommends test isolation as a core principle — [Playwright](https://playwright.dev/docs/browser-contexts) gives each test its own browser context, and [Cypress](https://docs.cypress.io/app/core-concepts/test-isolation) resets browser state before every test. But isolation means each test must set up its own data, and if that setup is a fixture file, you’re back where you started. ## Generate Data, Don’t Maintain It The alternative is to generate fresh data from the schema before each suite run, skipping the fixture file and the dump entirely. The workflow: 1. Start with an empty database (or [`TRUNCATE`](https://www.postgresql.org/docs/current/sql-truncate.html) the tables) 2. Generate the data your tests need 3. Run the tests 4. Tear down Each run gets fresh data matching the current schema, so nothing drifts and a new column shows up automatically. ## Seedfast as a Test Setup Step Seedfast slots in front of your E2E suite as a setup step. Point the CLI at the live database and it works out the schema, then writes realistic, relational data to match: ```bash # Before your E2E suite runs: seedfast seed --scope "5 users with 3 orders each, all with payments and shipping addresses" ``` Add a `NOT NULL` `currency` column next sprint, and the next run simply includes a value for it; the fixture file that would have needed editing no longer exists. The [`--scope` flag](/docs/scoping) describes what you need in plain language, and Seedfast works out how everything connects. You never sequence tables by hand, and the generated rows hold the same [referential integrity](/blog/referential-integrity) your fixtures kept breaking. ## Practical Example: E-Commerce Test Suite Say you have a Playwright test suite for an e-commerce application. The tests cover: - User registration and login - Product browsing and search - Cart operations - Checkout flow - Order history Each needs different data. The traditional route is five fixture files, ordered and maintained by hand. Generating it from the schema looks like this: ```bash # test-setup.sh #!/bin/bash set -e # Truncate all tables (clean slate) psql $DATABASE_URL -c "TRUNCATE users, products, categories, orders, order_items, payments, cart_items CASCADE" # Generate fresh data for this test run seedfast seed --scope "seed 10 users, 50 products across 5 categories, 20 orders with order items and payments" --output plain ``` Now your Playwright tests query for data instead of assuming it: ```javascript // Instead of: cy.visit('/users/42/orders') // Do this: test('user can view their order history', async ({ page }) => { // Query for a user that has orders (generated data guarantees this) const response = await page.request.get('/api/users?has_orders=true&limit=1') const user = (await response.json())[0] await page.goto(`/users/${user.id}/orders`) await expect(page.getByTestId('order-row')).toHaveCount.above(0) }) ``` The test carries no hardcoded IDs and no assumption about order counts; it verifies that a user with orders can see their history, never a specific value. ## Scenario-Specific Seeding Different test files seed different scenarios. The scope is just a string; describe what you need: ```bash # For testing empty states seedfast seed --scope "3 users with no orders" # For testing pagination seedfast seed --scope "1 user with 50 orders" # For testing search and filtering seedfast seed --scope "100 products across 10 categories with varied prices from 5 to 500 dollars" # For testing admin dashboards seedfast seed --scope "50 users with mixed roles: 2 admins, 5 managers, 43 regular users, each with activity logs" ``` Each scope produces data matching your current schema. You never spell out column names, types, or foreign-key values; the schema defines them and Seedfast fills the rows. ## CI/CD Integration: Seed, Test, Clean This fits a CI pipeline, the same pattern as [reseeding the CI database with synthetic data](/blog/synthetic-data-ci-cd) before every run: ```yaml name: E2E Tests on: [pull_request] jobs: e2e: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: testdb POSTGRES_USER: test POSTGRES_PASSWORD: test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Apply migrations run: npm run db:migrate env: DATABASE_URL: postgres://test:test@localhost:5432/testdb - name: Seed test data run: seedfast seed --scope "10 users with orders, products across categories, and reviews" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} DATABASE_URL: postgres://test:test@localhost:5432/testdb - name: Run E2E tests run: npx playwright test env: DATABASE_URL: postgres://test:test@localhost:5432/testdb - name: Upload test report if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report ``` The pipeline stays simple, applying the current schema, generating matching data, and running the tests. When a migration changes the schema the generated data changes with it, so the pipeline stays green without a fixture PR. The [CI/CD database seeding guide](/docs/cicd-database-seeding) covers the full setup, and [the service-container and health-check mechanics behind that YAML](/blog/github-actions-seed-postgres-database) get their own walkthrough. For isolation between test files, truncate and reseed between groups, with the table list written out by the [TRUNCATE all tables generator](/tools/postgres-truncate-all-tables) once it has outgrown typing by hand: ```javascript // playwright.config.js import { defineConfig } from '@playwright/test' export default defineConfig({ globalSetup: './tests/global-setup.ts', globalTeardown: './tests/global-teardown.ts', }) ``` ```javascript // tests/global-setup.ts import { execSync } from 'child_process' export default async function globalSetup() { // Truncate and reseed before the entire suite execSync('psql $DATABASE_URL -c "TRUNCATE users, orders, products CASCADE"', { stdio: 'inherit', }) execSync( 'seedfast seed --scope "10 users with orders and reviews, 50 products across 5 categories" --output plain', { stdio: 'inherit' } ) } ``` Seeding runs once for the whole suite and does not repeat before each test. A run takes seconds, sometimes a few minutes on larger volumes or schemas, which suits a per-suite setup step but would drag before every test. Structure your tests to read whatever data is present, and per-test seeding never comes up. ## Factory Pattern vs. Fixture Files vs. Generated Data Three approaches are common, each with trade-offs. | Dimension | Fixture files | Factory pattern | Generated data (Seedfast) | | --- | --- | --- | --- | | Maintenance on migration | Manual edit every change | Update the factory every change | Zero (Seedfast re-reads the schema) | | Schema drift | Drifts silently | Drifts less, still drifts | None (the schema is the source) | | Foreign-key handling | Manual ordering | Coded per relationship | Automatic (child rows land on real parents) | | Test isolation | Poor (shared state) | Good (per-test data) | Good (fresh per run) | | Determinism | High (fixed values) | High (seeded sequences) | Low (assert on shape, not values) | | Setup speed | Milliseconds | Milliseconds | Seconds (network call) | | Scales past ~20 tables | No | Painful | Yes | ### Fixture Files (JSON, SQL, YAML) ```json // fixtures/users.json [ { "id": 1, "email": "admin@test.com", "role": "admin", "department_id": 1 }, { "id": 2, "email": "user@test.com", "role": "user", "department_id": 2 } ] ``` **Pros:** They’re simple to understand, deterministic, and fast to load. **Cons:** They drift from the schema and need manual maintenance on every migration; hardcoded IDs create coupling, foreign keys have to be ordered by hand, and the approach stops scaling past a dozen tables. **Breaks when:** Any schema change that adds a `NOT NULL` column, moves a foreign key, or alters a constraint will break it, as will two fixture files that disagree about shared state (e.g., both reference department ID 1 but expect different names). ### Factory Pattern (Factory Bot, Fishery, test containers) ```javascript // factories/user.js import { Factory } from 'fishery' const userFactory = Factory.define(({ sequence }) => ({ id: sequence, email: `user-${sequence}@test.com`, name: `User ${sequence}`, role: 'user', department_id: departmentFactory.create().id, })) ``` **Pros:** Being programmatic, factories handle relationships and let each test build its own data, which keeps isolation clean. **Cons:** You write and maintain one per model, updating it on every schema change. Complex relationships (polymorphic associations, multi-level nesting) get messy, and the factory code is one more representation of your schema that can drift. **Breaks when:** Factories fall out of sync with migrations, though less often than fixtures since the code sits closer to the model. Tables without a corresponding model (join tables, audit logs, materialized views) still slip through. ### AI-Generated Data (Seedfast) ```bash seedfast seed --scope "10 users with orders across 5 product categories" ``` Where a tool like this lands among its AI-labeled peers is what [the best AI test data generator](/blog/best-ai-test-data-generator) comparison sorts out. **Pros:** Maintenance drops to zero. The schema itself is what Seedfast works from on every run, so the rows it produces already satisfy the foreign keys and constraints in it. A schema change shows up in the next run, and the table count doesn't change the workflow. **Cons:** Its data is non-deterministic, so assertions check structural properties, looking for “user has at least one order” and never “user 42 has order 101”. It needs a running database and network access to Seedfast’s backend, and loads slower than a fixture file (seconds vs. milliseconds). On a sensitive codebase, note that generating the data sends your table and column definitions (never the row values) to an external AI service, so a regulated project should clear that path against its data-governance policy first. **Breaks when:** It breaks if Seedfast’s backend goes unavailable, though API-key caching and retry logic soften that. The bigger cost is restructuring an existing suite around data-shape assertions, which is genuine migration work. ## When to Use What **Unit tests with no database** — Factory pattern or in-memory fakes **Integration tests (few tables)** — Factory pattern **E2E tests (full schema)** — Generated data **Performance/load testing** — Generated data at scale **Existing suite with hundreds of fixture files** — Gradual migration, with generated data for new tests and factories for legacy The approaches aren’t mutually exclusive; many teams pair factories for unit and integration tests with generated data for E2E and performance runs. ## Making Tests Data-Shape-Aware The biggest shift is how tests reference data. Where a fixture-bound test asserts on known values, a data-shape test queries for records with the properties it needs. ### Before (fixture-dependent): ```javascript test('admin can delete a user', async ({ page }) => { // Assumes fixture user with ID 1 is an admin await loginAs(1) // Assumes fixture user with ID 2 exists and is deletable await page.goto('/admin/users/2') await page.click('[data-testid="delete-user"]') await expect(page.getByText('User deleted')).toBeVisible() }) ``` ### After (data-shape-aware): ```javascript test('admin can delete a user', async ({ page, request }) => { // Find any admin user const admins = await request.get('/api/users?role=admin&limit=1') const admin = (await admins.json())[0] // Find any non-admin user const users = await request.get('/api/users?role=user&limit=1') const targetUser = (await users.json())[0] await loginAs(admin.id) await page.goto(`/admin/users/${targetUser.id}`) await page.click('[data-testid="delete-user"]') await expect(page.getByText('User deleted')).toBeVisible() }) ``` The second version is more resilient. It doesn’t care which user happens to be the admin or which one gets deleted; it tests the behavior that an admin can delete a non-admin user, and that holds up through schema changes, data reseeds, and parallel execution. Yes, it’s more code, and it earns its keep — the old version only ever proved that one specific sequence of clicks kept working against one specific dataset, which is a much smaller promise than the feature actually makes. ## A Pragmatic Migration Path If your E2E suite is built on fixtures, you don’t need to rewrite it all at once: **Week 1:** Add a seeding step to CI alongside your existing fixtures, and run both together. ```yaml - name: Load legacy fixtures run: psql $DATABASE_URL -f fixtures/seed.sql - name: Seed additional data run: seedfast seed --scope "seed 20 extra users with varied roles and order histories" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} ``` **Week 2-4:** Write new tests using the data-shape-aware pattern, and leave the old ones alone for now. **Month 2:** Find the fixture files that break most often (git blame shows which get updated every sprint) and migrate those first. **Month 3+:** As old fixture-dependent tests break from schema changes, rewrite them with the generated pattern and drop the fixture. Over time the fixture directory shrinks. New tests skip it, old ones migrate out as they break, and one `seedfast seed` command becomes the only setup left. ## What This Gets You Fewer flaky tests is what you notice first; underneath, your team’s whole approach to test data changes. **Schema changes stop breaking tests.** A migration adds a column, the next run already includes it, and no one files a fixture-update PR to catch up. **Test isolation becomes the default.** Every CI run starts from a fresh database with no shared state to inherit, so the suite parallelizes cleanly. **New developers can run the suite immediately.** Nobody hands them a database dump or a seed script from someone’s head; they clone, run migrations, seed, and it’s ready. **Your tests end up documenting the feature itself.** A test that reads “a user with orders can view their order history” checks that claim against any valid data, and at that point the test reads as a small specification of the feature. ## Frequently asked questions ### Why do my Playwright or Cypress tests fail after a database migration? The fixture they load no longer matches the migrated schema. Nothing flags a checked-in file when it falls behind, so the failure surfaces as an insert error or an unexpected null. Look in the fixture file, well upstream of the assertion that fails. ### What is the difference between test fixtures, factories, and generated test data? Fixtures are static files (JSON, SQL, YAML) you edit by hand on every schema change, while a factory keeps that record-building in code and still needs updating whenever a migration lands. Seedfast is the third option, reading the live schema at run time and picking the change up with no upkeep, at the cost of data that isn't deterministic run to run. ### How do you generate test data for E2E tests without fixture files? Point a generation step at the schema so there’s no fixture file in the loop. Seedfast reads the live tables and writes rows whose foreign keys all resolve to records that exist, so one seed step replaces the whole fixture directory. ### How do you keep E2E tests isolated so they can run in parallel? Give each run its own fresh data so nothing is shared between files, and drop the ordering assumptions that make one test wait on another. When every test sets up, runs, and tears down its own records, nothing is left to fight over, and parallel execution follows. ### Can you seed a test database in a CI pipeline? Yes. Put the step after migrations and before the test run in GitHub Actions, GitLab CI, or any pipeline, with `seedfast seed` pointed at the throwaway CI database and `--output plain` so nothing waits on input. Seedfast reads the schema those migrations just produced, which is what keeps the CI data matched to the code under test. Because it calls an external service, regulated teams should clear it with whoever owns data-governance policy first. ### Should you use a production database dump for E2E test data? Usually not, for the two reasons production dumps are risky anywhere. The moment a dump leaves production it carries real customer records into an environment with weaker access controls, and it starts aging as soon as the schema shifts. Seedfast generates from the schema instead, so the suite runs against rows nobody has to guard and nothing in the setup step goes stale between migrations. ## Related guides - [Small Data, Big Lies: 6 Bugs Only Real Test Data Catches](/blog/small-data-big-lies) — why a tiny fixture database hides the failures that only appear with realistic data - [Your Staging Database Is a Compliance Violation Waiting to Happen](/blog/staging-without-prod-data) — generating realistic data instead of copying and anonymizing a production dump - [ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site](/blog/migration-testing) — the other side of schema change: testing migrations against production-scale data - [Review SQL Migrations in 30 Seconds](/blog/migration-review) — catching the data-shape bugs a migration introduces before they reach your tests - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql) — the raw SQL patterns behind generating volume and realistic distributions - [Database Seed File Maintenance: Stop Patching seed.sql](/blog/seed-file-maintenance) — the same drift problem on the seed-script side instead of the fixture side - [Data Seeding Tools Compared](/blog/data-seeding-tools) — where fixtures, factories, and schema-aware generators fit among the other approaches - [Agentic QA Test Data](/blog/agentic-qa-test-data) — why even AI agents that write and run your E2E tests still don't generate the data underneath them - [Pytest Database Fixtures That Stay Fast and Honest](/blog/pytest-database-fixtures) — the backend/integration-test counterpart to this page: fixture scopes, the SQLAlchemy SAVEPOINT rollback pattern, and seeding a Postgres baseline once for pytest - [Get started with Seedfast](/docs/seed-your-database) — connect a database and seed it for your next test run ## Generate E2E test data straight from your schema [Seedfast](https://seedfa.st/) reads your live Postgres schema at the start of each suite run and writes the rows your Playwright or Cypress tests query for, which is what takes the fixture directory off somebody's maintenance list. [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *Point Seedfast at the schema once, and keeping test fixtures in sync stops being your team’s problem.* --- # Load Testing With an Empty Database? Here's Your Problem > Source: https://seedfa.st/blog/load-testing-data > Last updated: 2026-07-22 *You can polish the k6 scripts, tune every Gatling scenario, and arm the Locust swarm, but with only 47 rows in the database behind them, every number they collect is fiction.* Two weeks of work go into a load testing suite covering login, search, checkout, and reporting, with think times, ramp-up curves, and error thresholds modeled. The run comes back clean, showing P99 under 200ms, zero errors, and 3,000 requests per second, so the release ships on those numbers. Production then tells a different story, where search crawls to 8 seconds, the reporting endpoint times out, and the checkout flow that cleared 3,000 RPS in the test can barely hold 400, all of it aimed at a database that could never answer the question the load test was asking. ## Key Takeaways - An empty or near-empty database makes every load-test number fiction: query plans, buffer cache, and connection-pool dynamics are all data-dependent, so a system measured at 50 rows behaves nothing like one at 5 million. - The four numbers that move most: throughput typically drops 3–5x, P99 latency rises 10–50x, missing indexes surface, and the connection pool saturates — none of which an empty database reveals. - Realistic data volume matters more than any refinement to the load-test script, so your existing k6/Gatling/Locust scenarios end up hitting a production-shaped database. - [Seedfast](https://seedfa.st/) generates that volume straight from your live schema with every foreign key intact, and it slots in as a pipeline step ahead of the load test without touching your k6 or Gatling scripts. ## The Empty Database Illusion Most load tests run against trivially small datasets, an open secret in performance engineering. Nobody wants to spend a week building a data-generation pipeline when the goal is to test application performance, so teams take shortcuts: - Use the default dev database with 20 rows per table - Run migrations and insert a handful of fixture records - Copy a small CSV extract from production - Skip data setup entirely and hope the application layer is the bottleneck Databases don't behave the same way at 50 rows and 5 million; the difference is fundamental, not approximate. At scale the query planner makes different decisions and once-ignored indexes become critical, while disk I/O patterns change and buffer cache hit rates collapse. Connection pool behavior shifts too, and nested-loop joins give way to hash joins. Seedfast closes that gap in one step, reading your live schema and writing millions of connected rows into it, so the run finally measures a database the size production will hand you. ## What Changes at Scale: A Technical Walkthrough ### Query Plans Are Data-Dependent PostgreSQL's [query planner](https://www.postgresql.org/docs/current/planner-optimizer.html) uses table statistics — row counts, value distributions, null fractions — to choose execution plans. At 100 rows almost every query gets a sequential scan, because reading the whole table costs less than an index lookup's random I/O, so the planner correctly decides the index isn't worth it. At 10 million rows, the same query gets an index scan, a bitmap heap scan, or a parallel sequential scan, depending on selectivity, while a missing index produces a sequential scan that reads gigabytes where an index would have read kilobytes. ```SQL -- Illustrative timings (the shape of the change, not a benchmark of any hardware): -- With 100 rows: Seq Scan, 0.2ms (whole table fits in cache) EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42; -- With 10M rows, no index: Seq Scan, 4,200ms -- With 10M rows, with index: Index Scan, 0.3ms ``` Read those two plans in sequence. A customer lookup that returns in 0.2ms against 100 cached rows becomes a 4,200ms sequential scan at 10 million rows with no supporting index, dropping back to 0.3ms only once the index exists, so a load test that never leaves the first line measures a plan production will never run. ### Buffer Cache Behavior Changes Completely PostgreSQL's [shared buffer cache](https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS) works well when the working set fits in memory, and with a small database that condition always holds, so everything is cached, every query hits memory, latency stays sub-millisecond, and throughput is bounded only by CPU. Once the working set outgrows cache size at production scale, queries begin hitting disk and latency spikes while throughput drops, and the degradation isn't linear; it falls off a cliff the moment the cache hit rate crosses below a threshold. The mapping below is illustrative of that cliff shape, not a benchmark of any specific hardware: ```bash Database size: 50MB → Cache hit rate: 99.9% → Avg query: 0.5ms Database size: 5GB → Cache hit rate: 97% → Avg query: 2ms Database size: 50GB → Cache hit rate: 78% → Avg query: 45ms ``` The 0.5ms from your load test becomes 45ms in production, and a P99 that read 200ms turns into 2,000ms, leaving the capacity model you built off by an order of magnitude. ### Connection Pool Saturation With a small database, every query completes in microseconds and connections return to the pool almost instantly, so 20 connections can serve thousands of concurrent requests, each occupied for a tiny fraction of a second. At production scale queries take longer, connections stay checked out for milliseconds instead of microseconds, and the pool starts queuing. Each added millisecond lets more requests stack up, raising pool pressure and pushing the system into a feedback loop that doesn't exist at small scale. The figures below assume query time dominates the connection hold time, a simplification that shows the direction, not a literal throughput ceiling: ```bash Small DB: Query time 0.5ms → 20 connections handle 40,000 req/s Large DB: Query time 50ms → 20 connections handle 400 req/s → Requests start queuing at 400 req/s → Queue adds latency → more queuing → cascade ``` This is the mechanism that turns a test showing comfortable headroom into a production system that hits a wall. ### Index Usage and Join Strategies With small tables the planner ignores indexes entirely, and it doesn't matter because sequential scans on small tables are fast, so your load test never exercises index performance at all. At scale, missing indexes become the dominant performance factor, and compound indexes matter as well, since a query filtering on `customer_id` and `status` may use an index on `customer_id` yet still scan thousands of rows for the status filter without a compound index. The [bugs that only real test data catches](/blog/small-data-big-lies) walk through this same index-at-scale failure from the application side. Join strategies change as well, since small tables get nested loop joins while large tables get hash or merge joins, and those carry completely different memory requirements, CPU profiles, and performance characteristics. ```SQL -- Small tables: Nested Loop Join, 0.5ms -- Both tables fit in cache, inner loop is trivial SELECT o.*, c.name FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = 'pending'; -- Large tables: Hash Join, 180ms (or worse) -- Hash table may spill to disk, changing performance profile entirely ``` ## The Anti-Patterns Every performance engineering team has hit at least one of these. Some are hitting all four. ### Anti-Pattern 1: The Empty Database This is the most common and most dangerous of the four. The database holds whatever the migration scripts created, empty tables with correct schemas, and the load test runs fast because there's nothing to query. ```bash # "We tested it with k6, performance is fine" k6 run load-test.js # ✓ P99 < 200ms # ✓ Error rate < 0.1% # ✓ Throughput > 2000 RPS # (against a database with 0 rows in every table) ``` What runs here isn't a load test; it only shows how fast the application can hand back rows that aren't there. ### Anti-Pattern 2: The Dozen Rows This one is slightly better, since someone ran the dev seed script, so there are now 10-20 rows per table and the queries return data. Even so, the performance profile matches an empty database, because every table still fits in a single disk page. ### Anti-Pattern 3: The CSV Import Marathon The responsible engineer exports a CSV from production, scrubs it for PII (hopefully), and writes an import script, three days of work for a snapshot already a month stale. Half the [foreign keys are broken](/blog/referential-integrity) because the export missed related tables, and the script breaks on the next schema change with nobody left to update it. ```bash # The "proper" approach that nobody maintains psql staging_db -c "\COPY users FROM 'users_sanitized.csv' CSV HEADER" psql staging_db -c "\COPY orders FROM 'orders_sanitized.csv' CSV HEADER" # ERROR: insert or update on table "orders" violates foreign key constraint # (because the users CSV was from Monday and orders CSV was from Wednesday) ``` ### Anti-Pattern 4: The "Application Layer Is the Bottleneck" Assumption This is the most seductive anti-pattern, and the reasoning goes: "Our bottleneck is CPU-bound application logic, not the database. So database size doesn't matter." Sometimes it's true, but never for long. Optimize the application layer and the database becomes the bottleneck, by which point your load tests have baselined against an empty database, so you can't measure the improvement accurately. ## What Realistic Data Actually Reveals Teams that switch to realistic-data load testing keep hitting the same five issues, which come up repeatedly in practice, not in a lab benchmark. ### Finding 1: Your Actual Throughput Is Several Times Lower The endpoint that cleared 3,000 RPS on an empty database sustains 600-1,000 RPS with realistic data, because the bottleneck was always the database and empty tables kept it invisible. ### Finding 2: Your P99 Latency Is Far Higher Average latency rises modestly while tail latency explodes, since an occasional uncached or connection-starved query adds seconds to the P99, where your SLA lives, not on the average. ### Finding 3: You Have Missing Indexes Nearly every schema past 20 tables hides at least one missing index that only matters at scale, and realistic data triggers slow-query logs that stayed silent at 50 rows. Missing indexes are one of [six bug classes that only real test data catches](/blog/small-data-big-lies) — alongside N+1 queries, pagination off-by-ones, and timeout cascades. ### Finding 4: Your Connection Pool Is Undersized Pool sizing tuned to sub-millisecond queries collapses when queries take 10-50ms, and the real fix is usually the queries, not a bigger pool, since more connections just push contention into PostgreSQL's backends. ### Finding 5: Background Jobs Compete With Request Traffic At small scale, background jobs (reports, exports, analytics) finish instantly and leave request queries alone; at production scale they compete for the same connections, buffer cache, and I/O, contention an empty-database load test never surfaces. ## The Fix: Seed Before You Test Before running your load test, populate the database with realistic data at the volume you expect in production. ### Step 1: Seed Production-Volume Data ```bash # Seed production-scale data for a load test seedfast seed --scope "seed 500,000 users with profiles, 2,000,000 orders spread across the last 12 months, payments for each order, and product reviews for 10% of orders" ``` Seedfast reads your schema and generates foreign-key-valid data with [realistic distributions](/docs/data-realism), so timestamps cluster during business hours and order amounts follow the kind of curve a real store produces rather than a flat random spread. It adapts to migrations as they land, so a new column flows into the next run instead of breaking the way the hand-written CSV importer above does. ```bash Seeding Plan: public.users — 500,000 records public.profiles — 500,000 records public.products — 5,000 records public.orders — 2,000,000 records public.order_items — 5,800,000 records public.payments — 2,000,000 records public.reviews — 200,000 records Total: 11,005,000 records across 7 tables Approve? (Y/n) ``` ### Step 2: Run Your Load Test Now run the same k6/Gatling/Locust scripts you already have; nothing about them changes, except that this time the database actually has data in it. ```bash # Same test, dramatically different results k6 run load-test.js ``` ### Step 3: Compare Results The numbers will come out different, and that's the point, because the new ones sit much closer to what production will experience. The figures below are illustrative of the *shape* of the change (throughput down, tail latency up, pool saturated), not a benchmark of any specific application: ```bash Empty DB 500K Users P50 Latency 12ms 45ms P99 Latency 85ms 890ms Throughput 3,200 RPS 780 RPS Error Rate 0.01% 2.3% DB Connections Used 4/20 20/20 (saturated) ``` An error rate like that is what pool saturation produces, and a P99 that climbs like that is what a missing index on a hot query looks like. Both surface only against a realistically sized database. Give Seedfast your own schema and it builds one, so what the test measures is production-shaped data rather than an empty table, and you can [run your first seed](/docs/seed-your-database) against your own Postgres to see the difference. ## Integrating Into Your Load Testing Pipeline One-off load tests help, but the real value comes from running them automatically, seeding the database as a pipeline step ahead of each test. The [CI/CD database seeding guide](/docs/cicd-database-seeding) walks through the full setup. ### GitHub Actions ```yaml name: Load Test on: schedule: - cron: '0 2 * * 1' # Weekly Monday 2AM workflow_dispatch: jobs: load-test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: loadtest POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - 5432:5432 steps: - uses: actions/checkout@v4 - name: Run migrations run: npm run migrate env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/loadtest - name: Seed realistic data run: | seedfast seed \ --scope "seed 200,000 users with orders and payments" \ --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} DATABASE_URL: postgresql://postgres:postgres@localhost:5432/loadtest - name: Run load test run: k6 run --out json=results.json load-tests/main.js env: BASE_URL: http://localhost:3000 DATABASE_URL: postgresql://postgres:postgres@localhost:5432/loadtest - name: Check thresholds run: | # Fail the pipeline if P99 exceeds threshold python scripts/check-load-test-results.py results.json \ --p99-max 500 \ --error-rate-max 1.0 ``` ### GitLab CI ```yaml load-test: stage: performance services: - postgres:16 variables: POSTGRES_DB: loadtest DATABASE_URL: postgresql://postgres:postgres@postgres:5432/loadtest script: - npm run migrate - seedfast seed --scope "seed 200,000 users with orders and payments" --output plain - k6 run load-tests/main.js rules: - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "web" ``` ### Nightly Performance Regressions The most powerful pattern is to seed fresh data and run load tests nightly, comparing each run with the last so performance regressions surface before they reach production. ```bash #!/bin/bash # nightly-load-test.sh # Fresh database every run dropdb --if-exists loadtest && createdb loadtest npm run migrate # Seed consistent volumes seedfast seed \ --scope "seed 100,000 users with orders, payments, and activity logs" \ --output plain # Run load test with consistent parameters k6 run \ --out json=results/$(date +%Y-%m-%d).json \ load-tests/main.js # Compare with yesterday's results python scripts/compare-results.py \ results/$(date -d yesterday +%Y-%m-%d).json \ results/$(date +%Y-%m-%d).json \ --regression-threshold 15 ``` ## Sizing Your Test Data | Goal | Minimum volume | What it reveals | | --- | --- | --- | | Basic query plans | 10,000+ rows | Whether indexes are being used | | Cache behavior | Data > 25% of shared_buffers | Real-world cache hit rates | | Connection pool sizing | Enough to make queries take >5ms | Pool saturation points | | Join performance | 100,000+ rows per joined table | Hash join vs. nested loop thresholds | | End-to-end realism | Match production row counts | Realistic full-pipeline performance | Start with roughly 10x your current test data, so 100 rows per table becomes 1,000, then run the load test and step up to 10,000 and 100,000, watching for the volume where the performance profile changes, since that's where your production issues hide. ```bash # Progressive scaling approach seedfast seed --scope "seed 1,000 users with orders and payments" k6 run load-test.js # Baseline # Drop and re-seed at higher volume seedfast seed --scope "seed 10,000 users with orders and payments" k6 run load-test.js # Look for non-linear degradation seedfast seed --scope "seed 100,000 users with orders and payments" k6 run load-test.js # This is usually where things get interesting ``` The volume where latency stops scaling linearly is where your architecture has a bottleneck, and finding that inflection point is the whole purpose of load testing with realistic data. Re-seeding between volume tiers takes minutes, not the three-day CSV marathon; in our internal runs, Seedfast generated about 1M foreign-key-valid rows on a 20-table SaaS schema in roughly 3.5 minutes (local Postgres, single run). For the highest volumes, the [large-volume seeding guide](/docs/large-volume-seeding) covers batching and scope tuning. ## Load testing as performance theater Load testing without realistic data is performance theater. It generates charts and reports and clears the compliance checkbox, yet none of that predicts how the system behaves in production, where "P99 under 200ms" on an empty database routinely becomes "P99 at 2 seconds," which is the difference between a clean launch and a 2AM incident. You already invest real effort in writing load test scenarios, tuning parameters, and analyzing results. The one missing piece is the data; once the database holds production-shaped volume, those same tests start telling you the truth. ```bash seedfast seed --scope "seed production-scale data across all tables" ``` Then run the test you already have, and this time the results it hands back will be real ones. ## Frequently asked questions ### Why does an empty database make load test results inaccurate? Database performance is data-dependent. With a near-empty table, PostgreSQL's planner picks a sequential scan because the table is cheaper to read whole, every read is served from the buffer cache, and connections return to the pool in microseconds. At production volume the same query may need an index scan or hash join, reads start hitting disk, and connections stay checked out long enough to saturate the pool. So an empty-database load test measures how fast your application returns near-empty result sets, not how it handles production query load. ### How much test data do I need for a realistic load test? Match production row counts when you can; when you cannot, start at roughly 10x your current test volume and scale up in tiers (1,000 → 10,000 → 100,000 → 1,000,000), running the load test at each step. Index usage shows up around 10,000 rows, cache behavior once the data exceeds about 25% of `shared_buffers`, and join-strategy changes around 100,000 rows per joined table. Watch for the volume where latency stops scaling linearly, since that inflection is where your bottleneck lives. ### Does database size really change PostgreSQL query plans? Yes — the plan is chosen from what the statistics say about the data, so the same query that gets a sequential scan at 100 rows may get an index scan, a bitmap heap scan, or a parallel scan at 10 million, and a missing index turns into a scan that reads gigabytes. Join strategy shifts with size as well, from nested loops toward hash or merge joins with very different memory and CPU profiles. Run [`EXPLAIN ANALYZE`](https://www.postgresql.org/docs/current/sql-explain.html) on realistic volumes to see the plan production will actually run. ### Can I just copy production data into my load test environment instead? It's a common shortcut with two separate problems. First, whether you're even allowed to move real production records into a test environment is a compliance question, often restricted under regimes like HIPAA or GDPR, so check with your own compliance team, not a load-test guide. Second, it's brittle, since production dumps drift from the schema and break on the next migration, and partial exports leave foreign keys dangling. Seedfast addresses the second problem by generating foreign-key-valid data from your schema, so no real customer records enter the load-test pipeline. It isn't an anonymization or compliance tool, and it doesn't make copying prod data safe so much as remove the reason to copy it at all. ### How do I add database seeding to a load testing CI pipeline? To add database seeding to a load-testing CI pipeline, add a seed step after migrations and before the load test. Set `SEEDFAST_API_KEY` in the environment to run the CLI non-interactively (no confirmation prompt) and pass your volume as a plain-English `--scope`. Seedfast then reads the schema those migrations just produced and writes the volume you asked for, and your existing k6, Gatling, or Locust scenario runs against it unchanged. The [CI/CD database seeding guide](/docs/cicd-database-seeding) shows the full GitHub Actions and GitLab CI setup. ## Related guides - [Small Data, Big Lies: 6 Bugs Only Real Test Data Catches](/blog/small-data-big-lies) — the same volume blind spot from the application side: the bug classes that stay hidden until your tables hold real volume - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql) — the raw SQL patterns for generating volumes and realistic distributions by hand - [ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site](/blog/migration-testing) — what happens when a migration meets production-scale data - [Enterprise Database Test Data: What It Actually Looks Like](/blog/enterprise-database-test-data) — realistic relational test data across a large, multi-table schema - [Get started with Seedfast](/docs/seed-your-database) — connect your database and run your first schema-aware seed ## Ready to see what your load tests are actually measuring? [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *[Seedfast](https://seedfa.st/) turns your live schema into production-scale test data with valid relationships and realistic volumes, and there are no seed scripts left for you to maintain.* --- # Review SQL Migrations in 30 Seconds: Seed, Migrate, Compare > Source: https://seedfa.st/blog/migration-review > Last updated: 2026-07-22 *When you review application code, the tests show what it does. A migration arrives as raw SQL that reads fine and gets approved, right up until it meets real data. This is a quick way to check what it actually does first.* A migration PR gives a reviewer one artifact to judge, the SQL itself. Take `ALTER TABLE users ALTER COLUMN email TYPE TEXT`, a single clean line that reads fine and gets waved through alongside application PRs whose tests you can check. What the diff never shows is what that line does to four million existing rows. Run it against production and the type change rewrites the whole table, which the team planned for. The quieter problem is what the change removed. The old `VARCHAR(255)` column capped every email at 255 characters, and a downstream billing export leaned on that cap to fit its own fixed-width `email` field. Once the column is `TEXT` with no limit, longer sign-up addresses flow straight through and the export starts rejecting payloads it was never built to hold. The SQL was correct and the change made sense; nobody had looked at what it would do to the rows already there. ## Key Takeaways - Reading migration SQL checks syntax and intent. It can't show what the migration does to real data — the NULLs, duplicates, and out-of-range values that only exist once a table has been in production a while. - A migration review needs three things a diff can't give you: the before state, the after state, and a diff of the *data*, not just the schema. - What breaks is rarely the syntax; it's what the statement does once real values run through it. A valid `UPDATE ... = a || b` writes NULLs wherever a name is NULL, an `ADD CONSTRAINT UNIQUE` that reads fine fails on deploy against existing duplicates, and a compiling type cast rounds money to whole units. - Correctness testing (production-*shaped* data: NULLs, edge cases, ~500 rows) is a different question from performance testing (production-*scale* data: millions of rows), which is the territory of [migration testing at scale](/blog/migration-testing). - [Seedfast](https://seedfa.st/) fills a review database from the live schema with FK-valid rows that carry the NULLs and near-duplicates production accumulates, which is the variety a migration has to survive. ## The Code Review Blind Spot Code review runs a familiar loop — read the change, check the tests, confirm the behavior matches the intent. Add a new endpoint and the test suite spells out exactly which inputs produce which outputs, and the same tests prove a refactor preserved the old behavior. Migrations have none of this. A typical migration PR looks like: ```SQL -- migrations/20260226_consolidate_user_names.sql ALTER TABLE users ADD COLUMN full_name VARCHAR(200); UPDATE users SET full_name = first_name || ' ' || last_name; ALTER TABLE users DROP COLUMN first_name; ALTER TABLE users DROP COLUMN last_name; ``` As a reviewer, you read this and think, "Okay, concatenating first and last name into a single column. Makes sense." You scan for the obvious mistakes, a missing `WHERE` clause or a wrong column name or bad syntax, and approve. What you didn't check: - Some rows have a NULL `first_name`, where the concatenation evaluates to `NULL || ' ' || 'Smith'` = `NULL` and `full_name` ends up empty where you expected a name. - Other rows carry leading or trailing spaces in `first_name` or `last_name`, so `" John " || " " || "Smith"` collapses into a doubled-space `" John Smith"`. - And when `first_name + ' ' + last_name` runs past 200 characters, the `UPDATE` either truncates silently or throws, depending on your database settings. None of this is an edge case; it's the normal state of any database that has been in production more than six months, and reading the SQL will never surface it. You see it only by running the migration against data shaped like production. ## What Reviewers Actually Need Reviewing application code, you judge behavior from the test output, not the syntax of the source. Migration reviews should work the same way, and a reviewer actually needs: 1. **Before state:** What does the data look like right now? 2. **After state:** What does the data look like after the migration? 3. **Diff:** What changed, and does the change match the stated intent? If a migration says "consolidate first and last name into full_name," the reviewer should see a sample of real rows before and after, not just imagine them. The catch is that this needs data, and a local database of 12 tidy rows like "John Smith" and "Jane Doe" will never surface the NULL concatenation bug, the stray spaces, or the truncation. ## The Workflow: Seed, Migrate, Compare The fix is a three-step workflow that takes about 30 seconds of hands-on time. ### Step 1: Seed Realistic Data Start with a database on your current schema (before the migration) and fill it with realistic variety, meaning NULLs, edge-case strings, varying lengths, and the messiness that accumulates in production. ```bash # Seed users with realistic variation seedfast seed --scope "500 users with varied names, some with NULL first or last names, international characters" ``` Seedfast generates [data that reflects real-world patterns](/docs/data-realism), so some names run short and others long, some fields come back NULL, and unicode shows up where it would in production. That is the real difference from hand-crafted fixtures, which only hold the cases you already thought of. You describe the variety you want in plain English as a [scope](/docs/scoping), and since Seedfast reads your live schema, it seeds [FK-valid rows](/blog/referential-integrity) without a fixture script to keep in sync. ### Step 2: Snapshot, Migrate, Snapshot Capture the before state, run the migration, capture the after state. ```bash # Snapshot the before state psql $DATABASE_URL -c "SELECT first_name, last_name FROM users LIMIT 20" > before.txt # Run the migration psql $DATABASE_URL -f migrations/20260226_consolidate_user_names.sql # Snapshot the after state psql $DATABASE_URL -c "SELECT full_name FROM users LIMIT 20" > after.txt ``` ### Step 3: Inspect the Result Now look at what actually happened: ```bash # Check for NULLs that shouldn't be there psql $DATABASE_URL -c "SELECT count(*) FROM users WHERE full_name IS NULL" # Check for unexpected whitespace psql $DATABASE_URL -c "SELECT full_name FROM users WHERE full_name LIKE '% %' OR full_name LIKE ' %' LIMIT 10" # Check for truncation psql $DATABASE_URL -c "SELECT full_name, length(full_name) FROM users ORDER BY length(full_name) DESC LIMIT 5" ``` Clean results here mean the migration behaves. If they come back dirty, you have just caught a production bug from your laptop in under a minute. ## Five Scenarios Where This Catches Real Bugs ### 1. NULL Concatenation The whole change is a single backfill line: ```SQL UPDATE users SET full_name = first_name || ' ' || last_name; ``` The trouble shows up on any row where `first_name` or `last_name` is NULL, which produces a NULL `full_name`, since in PostgreSQL [`NULL` concatenated with anything is `NULL`](https://www.postgresql.org/docs/current/functions-string.html). What the reviewer sees after seeding: ```bash count ------- 47 (1 row) ``` 47 users just lost their names, and the fix is obvious once you see it. Wrap each column in [`COALESCE`](https://www.postgresql.org/docs/current/functions-conditional.html#FUNCTIONS-COALESCE-NVL-IFNULL) so a NULL becomes an empty string and can't poison the whole expression: ```SQL UPDATE users SET full_name = COALESCE(first_name, '') || ' ' || COALESCE(last_name, ''); ``` ### 2. NOT NULL Constraint on a Column With Existing NULLs The migration: ```SQL ALTER TABLE orders ALTER COLUMN shipping_address SET NOT NULL ``` The reviewer reads this and thinks, "Good, shipping address should be required," and approves. But in production, 8% of orders are digital-only and have no shipping address, so the migration fails on deploy. After seeding 500 orders with realistic variety: ```bash psql $DATABASE_URL -c "SELECT count(*) FROM orders WHERE shipping_address IS NULL" ``` ```bash count ------- 41 (1 row) ``` The reviewer now knows the migration needs a backfill step first, or a different path for digital orders. ### 3. Column Type Change That Loses Data Here the change retypes a money column: ```SQL ALTER TABLE products ALTER COLUMN price TYPE INTEGER USING price::INTEGER; ``` The stated intent is "we don't need decimal precision, let's simplify," and the cast syntax checks out, so it gets approved. But [`ALTER COLUMN ... TYPE`](https://www.postgresql.org/docs/current/sql-altertable.html) rewrites every value through the `USING` cast, and casting `numeric` to `integer` rounds each price to a whole number. After seeding: ```bash price --------- 29.99 14.50 99.95 7.25 149.99 ``` Every one of those prices is rounded to the nearest whole number, so `$29.99` becomes `$30` and `$7.25` becomes `$7`. Across thousands of products, that drift adds up to thousands of dollars in the reports, and the reviewer catches it instantly by looking at the data, not the DDL. ### 4. DEFAULT Value That Doesn't Match Business Logic The migration: ```SQL ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user' ``` It looks harmless enough, but after seeding and inspecting the existing rows: ``` role | count ----------+------- user | 412 admin | 23 manager | 65 ``` Look again, and every existing row now has `role = 'user'`, including the 23 admins and 65 managers. If the application stored role information in a different table or field before, this migration just demoted every admin to a regular user, since the `DEFAULT` lands on every existing row when the column is new. The fix is to add the column as nullable first, backfill from the authoritative source, then add the NOT NULL constraint. ### 5. Unique Constraint on Non-Unique Data The last one adds a uniqueness guarantee on email: ```SQL ALTER TABLE customers ADD CONSTRAINT unique_email UNIQUE (email) ``` After seeding: ```bash email | count ------------------------+------- john.smith@example.com | 3 info@company.org | 2 ``` On deploy, the migration fails, because duplicate emails already exist, whether from a legacy import or a bulk insert that skipped its uniqueness check, and a correct constraint can't paper over data that isn't ready for it. Five different bugs, five different fixes (a `COALESCE`, a backfill, a column redesign, a data cleanup), and the one thing they share is that a reviewer reading the SQL sees none of them coming. ## Making It Repeatable: The Review Script Wrap the workflow into a script that any reviewer can run: ```bash #!/bin/bash # review-migration.sh — Validate a migration against realistic data set -e MIGRATION_FILE=$1 DATABASE_URL=${DATABASE_URL:-"postgres://dev:dev@localhost:5432/migration_review"} if [ -z "$MIGRATION_FILE" ]; then echo "Usage: ./review-migration.sh " exit 1 fi echo "=== Step 1: Reset database ===" dropdb --if-exists migration_review createdb migration_review echo "=== Step 2: Apply base schema ===" for f in migrations/*.sql; do if [ "$f" != "$MIGRATION_FILE" ]; then psql $DATABASE_URL -f "$f" 2>/dev/null || true fi done echo "=== Step 3: Seed realistic data ===" seedfast seed --scope "Realistic data across all tables, include NULLs and edge cases" --output plain echo "=== Step 4: Capture before state ===" pg_dump $DATABASE_URL --data-only --inserts > /tmp/before_data.sql psql $DATABASE_URL -c "\d+" > /tmp/before_schema.txt echo "=== Step 5: Run migration ===" echo "Running: $MIGRATION_FILE" if ! psql $DATABASE_URL -f "$MIGRATION_FILE" 2>/tmp/migration_errors.txt; then echo "MIGRATION FAILED:" cat /tmp/migration_errors.txt exit 1 fi echo "=== Step 6: Capture after state ===" psql $DATABASE_URL -c "\d+" > /tmp/after_schema.txt echo "=== Step 7: Show schema diff ===" diff /tmp/before_schema.txt /tmp/after_schema.txt || true echo "=== Step 8: Validate data integrity ===" psql $DATABASE_URL -c " SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND is_nullable = 'NO' AND column_default IS NULL ORDER BY table_name, column_name; " echo "=== Done. Review the output above. ===" ``` Now any reviewer can run `./review-migration.sh migrations/20260226_consolidate_user_names.sql` and see the migration's real effect before approving the PR. ## CI/CD: Automated Migration Validation in PR Checks Don't rely on reviewers remembering to run the script. Make it part of your PR pipeline. ```yaml name: Migration Review Check on: pull_request: paths: - 'migrations/**' jobs: validate-migration: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: review POSTGRES_USER: review POSTGRES_PASSWORD: review ports: - 5432:5432 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Apply base schema run: | for f in migrations/*.sql; do psql $DATABASE_URL -f "$f" 2>/dev/null || true done env: DATABASE_URL: postgres://review:review@localhost:5432/review - name: Seed realistic data run: seedfast seed --scope "Realistic dataset across all tables, include NULLs and varied data" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} DATABASE_URL: postgres://review:review@localhost:5432/review - name: Apply new migration(s) run: | NEW_MIGRATIONS=$(git diff --name-only origin/main -- migrations/ | sort) for f in $NEW_MIGRATIONS; do echo "--- Applying: $f ---" psql $DATABASE_URL -f "$f" done env: DATABASE_URL: postgres://review:review@localhost:5432/review - name: Validate post-migration state run: | echo "=== Row counts after migration ===" for table in $(psql $DATABASE_URL -t -c "SELECT tablename FROM pg_tables WHERE schemaname='public'"); do psql $DATABASE_URL -c "SELECT '$table' as table_name, count(*) as rows FROM $table" done echo "=== Check for unexpected NULLs ===" psql $DATABASE_URL -c " SELECT c.table_name, c.column_name, c.is_nullable FROM information_schema.columns c WHERE c.table_schema = 'public' ORDER BY c.table_name, c.ordinal_position; " env: DATABASE_URL: postgres://review:review@localhost:5432/review ``` When a PR touches a migration, the pipeline seeds realistic data, runs the migration, and reports what happened, so reviewers judge from concrete evidence in the checks and never have to execute the SQL in their heads. For the whole setup, including a fresh per-PR database that gives every migration its own isolated copy, see the [CI/CD database seeding guide](/docs/cicd-database-seeding). ## The Difference Between Performance Testing and Correctness Testing Testing migrations against production-scale data (millions of rows) answers the question "will this migration be fast enough?" That's about lock duration, rewrite time, and index build speed — the failure mode behind [the ALTER TABLE that locked an 8-million-row table for 40 minutes](/blog/migration-testing). Testing against production-shaped data (realistic variety, NULLs, edge cases, messy strings) answers a different question, "will this migration produce correct results?" That's about data integrity, constraint violations, and silent data loss, the same volume-and-variety blind spot that hides [the bugs only real test data catches](/blog/small-data-big-lies). Catching a NULL concatenation bug doesn't take 5 million rows; it takes 500 that carry realistic NULL patterns, and exposing a unique constraint that breaks on duplicates just takes a dataset containing a few. This article is about the second kind — showing reviewers what a migration does to the data while the PR is still open, leaving the performance question for the deploy pipeline. ## A SQL Migration Review Checklist Before approving any PR that includes a schema migration: ### Data integrity - Seed realistic data and run the migration locally (or check the CI results) - Verify no unexpected NULLs were introduced - Verify no data was silently truncated or rounded - Verify row counts are unchanged (unless rows are intentionally removed) - Check that new DEFAULT values make sense for existing rows, not just new ones ### Constraint safety - If adding NOT NULL: confirm no existing rows violate the constraint - If adding UNIQUE: confirm no existing duplicates - If adding a FOREIGN KEY: confirm all referenced rows exist and [referential integrity](/blog/referential-integrity) holds across the data you already have - If changing a column type: confirm all existing values fit the new type ### Behavioral correctness - String operations handle NULLs (use COALESCE or explicit NULL checks) - Backfill queries produce correct results on a sample of real-shaped data - The migration is idempotent or has a working rollback ### Review process - PR includes what the migration does AND why (not just the SQL) - If the migration is multi-step, each step's purpose is documented - CI migration validation check passed ## Frequently asked questions ### How do you review a database migration before running it in production? **You review a migration by running it against realistic data, then comparing the before state, the after state, and the diff between them.** Start a database on the current schema, seed it with production-shaped data (NULLs, edge-case strings, duplicates, varied lengths), snapshot the affected rows, run the migration, and snapshot again. The difference between those snapshots is what you're actually approving, and only running the migration against real-shaped rows will show it. Seedfast builds that starting database from the schema you are about to change, so the rows in the before snapshot are the ones the migration will actually meet. ### Why isn't reading the migration SQL enough? **Because most migration bugs live in the data, not the syntax.** `UPDATE users SET full_name = first_name || ' ' || last_name` parses fine and still writes NULL into every row with a NULL name. The constraint `ADD CONSTRAINT ... UNIQUE` sails through review, then fails on deploy the moment duplicates already exist. Cast a column to `INTEGER` and every price quietly loses its cents. All three read correctly as SQL, and the defect surfaces only once the statement meets real-shaped data. ### What is the difference between testing a migration for performance and for correctness? **Performance testing uses production-scale data (millions of rows) to answer "will this migration be fast enough?"; correctness testing uses production-shaped data (realistic variety at small volume) to answer "will this migration produce the right data?"** Lock duration and rewrite time scale with row count, so performance testing needs volume. Constraint violations and silent data loss depend on the *shape* of the data, so correctness testing only needs a few hundred rows with realistic variety. The two are separate checks: [performance belongs before deploy](/blog/migration-testing), correctness belongs in code review. ### How much test data do you need to review a migration's correctness? **A few hundred rows with realistic variety is usually enough.** You don't need 5 million rows to catch a NULL concatenation bug, just ~500 that include the NULLs, duplicates, and edge-case strings production accumulates. What matters is variety over volume, since a dataset with a duplicate email surfaces a failing `UNIQUE` constraint and one with NULL names surfaces a NULL concatenation. Clean fixtures of 12 perfectly-formatted rows surface neither. ### Can I use a copy of production data to review migrations? **You can, but copying production data into dev or CI carries PII and compliance exposure that often outweighs the benefit.** Moving real production records into lower environments runs into data-minimization and access-control requirements under regimes like GDPR and HIPAA, and the dump drifts from the schema between refreshes anyway. Generating production-*shaped* data from the schema gives you the same correctness signal, the NULLs and duplicates and edge cases, without real records in the pipeline. In a regulated codebase, note that Seedfast reads your schema (the table and column definitions, not the rows) and uses an external AI service to generate from it, so confirm that its schema handling fits your own data-governance rules. See [staging without production data](/blog/staging-without-prod-data) for the full trade-off. ### How does Seedfast help review migrations? **Seedfast hands the reviewer a database full of FK-valid, production-shaped rows to run the migration against.** You point it at a local review database or a throwaway CI database, never production, where it reads only the schema, its structure and not your rows. The next run picks up whatever the migration changed, so there is no fixture script to maintain, and the free plan doesn't ask for a card. ## Related guides - [ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site](/blog/migration-testing) — the performance cousin of this article: testing migrations against production-*scale* data for lock duration and rewrite time - [Small Data, Big Lies: 6 Bugs Your Test Suite Will Never Catch](/blog/small-data-big-lies) — the volume-and-variety blind spot behind correctness bugs that 10-row test databases hide - [What is Referential Integrity?](/blog/referential-integrity) — why a migration that adds `NOT NULL REFERENCES` fails on existing orphan rows, and the producer-side discipline that prevents it - [Circular Foreign Key Seed](/blog/circular-foreign-key-seed) — the FK-loop case that makes seeding realistic review data non-trivial, and three Postgres patterns that handle it - [Your Staging Database Is a Compliance Violation Waiting to Happen](/blog/staging-without-prod-data) — generating realistic review data instead of copying and anonymizing production - [Get started with Seedfast](/docs/seed-your-database) — connect your database and seed production-shaped data for your next migration review ## Give a Migration the Same Review You Give Code Nobody approves application code by reading the source and imagining what it does; we run the tests, watch the output, and confirm the behavior is what we expected. A schema migration deserves that same rigor, because SQL that looks correct and reads as sensible can still do the wrong thing the instant it touches real rows, and the only way to know is to run it against data shaped like yours. Seeding realistic data, applying the migration, and comparing the before and after takes about 30 seconds of hands-on work, and it catches the data-shape bugs that reading the SQL alone keeps letting through. [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *Seedfast generates realistic, FK-valid test data from your schema so you can see what a migration actually does to that data before you approve it.* --- # ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site > Source: https://seedfa.st/blog/migration-testing > Last updated: 2026-07-22 *A migration that takes 50ms on your dev database can lock a production table for 20 minutes, and running it against production-scale volume first is how you catch that before your users do.* The Thursday-afternoon deploy looked harmless. Adding a `NOT NULL` column with a default value to the `orders` table had passed review and run fine on staging, and the pipeline showed green. Then production went quiet, the way an on-call channel goes quiet, because the `orders` table, all 8 million rows of it, sat locked while every API endpoint that touched orders queued and the load balancers began returning 502s. Forty minutes passed before the locks released and the retro could start. The schema change was exactly what the team needed, and the outage came down to a single missing step — nobody had run it against 8 million rows anywhere but production. This article covers migration *performance*, meaning lock duration, rewrite time, and the row counts that turn a 50ms change into a 40-minute outage. Every measurement below depends on having those row counts somewhere other than production, and Seedfast generates them from your own schema so the table you time has the shape and the size of the real one. Its correctness companion, catching NULL concatenations and failed constraints before they ship, is [how to review a migration against realistic data](/blog/migration-review). ## Key Takeaways - **Migration time scales with data volume.** A change that runs in 50ms on 50 development rows can hold a lock for minutes on millions of production rows. - **The dangerous operations rewrite or scan the whole table** — backfilling a new column, a non-concurrent `CREATE INDEX`, an `integer`-to-`bigint` type change, and validating a new `FOREIGN KEY`. Each one holds a lock proportional to row count. - **Some changes are metadata-only and safe at any size** — adding a column with a constant default (PostgreSQL 11+), widening a `varchar`, or `varchar` to `text`. Reading the SQL won't tell you which class you're in. - **Lock duration, not total runtime, is what takes the site down.** A two-minute migration that never holds a lock longer than 200ms is safer than a 30-second one that locks the table the entire time. - **[Seedfast](https://seedfa.st/) fills a throwaway database to production row counts straight from your live schema**, so you can time the migration and watch `pg_locks` before the change goes anywhere near a real table. The data-shape side of the same check, at small volume, lives in [migration review](/blog/migration-review). ## The Migration Time Bomb Schema migrations carry a property most code changes lack. Their execution time scales with data volume, because on many operations PostgreSQL rewrites or scans every row rather than only updating metadata. This creates a class of problems that are completely invisible in development: | Operation | 100 rows | 1M rows | 5M rows | | --- | --- | --- | --- | | `ADD COLUMN ... DEFAULT` (pre-PG 11) | < 1ms | 8s | 42s | | `ALTER COLUMN TYPE` (integer to bigint) | < 1ms | 12s | 65s | | `CREATE INDEX` (single column) | < 1ms | 4s | 22s | | `ADD COLUMN NOT NULL DEFAULT` (PG 11+) | < 1ms | < 1ms | < 1ms | | `CREATE INDEX CONCURRENTLY` | < 1ms | 6s | 35s | *Figures are illustrative orders of magnitude on commodity hardware; real numbers depend on your hardware, table width, indexes, and concurrent load.* That fourth row is worth a look. [PostgreSQL 11 made `ADD COLUMN ... DEFAULT` a metadata-only operation](https://www.postgresql.org/docs/current/ddl-alter.html#DDL-ALTER-ADDING-A-COLUMN) for constant defaults, while the other operations still rewrite or scan the table and even the fast ones have nuances that bite at scale. ## 5 Migrations That Look Harmless at 100 Rows ### 1. Adding a NOT NULL Column With Backfill ```SQL -- Step 1: Looks fine ALTER TABLE orders ADD COLUMN region VARCHAR(50); -- Step 2: The backfill that locks the table UPDATE orders SET region = 'us-east-1' WHERE region IS NULL; -- Step 3: Now add the constraint ALTER TABLE orders ALTER COLUMN region SET NOT NULL; ``` The backfill in step 2 is where it goes wrong. A single `UPDATE` touching 5 million rows acquires row locks across the entire table, so concurrent writes block, and autovacuum can't reclaim the dead tuples the backfill creates while the transaction stays open. If you have foreign key references, those tables might lock too. **At scale**, a 5M-row backfill `UPDATE` can take 30+ seconds and block all concurrent writes to the table for the duration. **The safer pattern:** ```SQL -- Batch the backfill UPDATE orders SET region = 'us-east-1' WHERE id IN (SELECT id FROM orders WHERE region IS NULL LIMIT 10000); -- Repeat until no rows remain ``` ### 2. Creating an Index on a Large Table ```SQL -- Acquires a SHARE lock — blocks writes for the entire build CREATE INDEX idx_orders_customer_id ON orders (customer_id); ``` On 100 rows it finishes instantly, but on 10 million rows it holds a `SHARE` lock for the entire index build, so every `INSERT`, `UPDATE`, and `DELETE` on that table queues behind it. **The safer pattern:** ```SQL -- Builds the index without blocking writes CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id); ``` [`CONCURRENTLY`](https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY) doesn't block writes, but it takes roughly 2-3x longer and can fail if there are concurrent schema changes. It also can't run inside a transaction block, which means most migration frameworks need special handling. ### 3. Changing a Column Type ```SQL -- Widening an integer primary key that's about to overflow ALTER TABLE events ALTER COLUMN id TYPE BIGINT; ``` Every growing team eventually runs this one, an `integer` ID column approaching its ~2.1 billion ceiling. It reads like a one-line metadata tweak, but because `integer` and `bigint` have different on-disk representations, PostgreSQL has to [rewrite the entire table](https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-NOTES) while holding an `ACCESS EXCLUSIVE` lock, so a 5M-row table stays fully locked until it finishes. The catch is that not every type change rewrites. Widening a `varchar`, or going from `varchar` to `text`, is binary-coercible, so PostgreSQL changes only the catalog and returns instantly even on 50 million rows, while `id INTEGER` to `id BIGINT` rewrites the whole table. With an enum, the expensive side of that line arrives the moment a label has to go, since PostgreSQL implements no `ALTER TYPE ... DROP VALUE` and the rebuild moves the column onto a freshly created type under the same `ACCESS EXCLUSIVE` lock, a script [the enum migration generator](/tools/postgres-enum-migration-generator) writes along with its rollback. **The safer pattern for a true rewrite** adds a new column, backfills in batches, swaps with a rename, and drops the old column, which is more steps but never holds an extended lock. ### 4. Adding a Foreign Key Constraint ```SQL ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id); ``` Adding a foreign key makes PostgreSQL validate every existing row, a full table scan over 5 million orders while it holds a lock on both `orders` and `customers`. If `customers` is also large, you've locked two critical tables at once, and it only works if every order already points at a real customer, the kind of [referential integrity](/blog/referential-integrity) that production data drifts away from over time. **The safer pattern:** ```SQL -- Add the constraint without validating existing rows ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID; -- Validate in a separate step (holds a weaker lock) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer; ``` [`NOT VALID`](https://www.postgresql.org/docs/current/sql-altertable.html) adds the constraint for new rows immediately, and then `VALIDATE` checks the existing rows under a less aggressive lock whose duration still tracks the row count. ### 5. Dropping a Column (Yes, Really) ```SQL ALTER TABLE orders DROP COLUMN legacy_status; ``` In PostgreSQL, `DROP COLUMN` doesn't rewrite the table; it only marks the column as invisible. That sounds fast, and usually it is, but the operation still takes an `ACCESS EXCLUSIVE` lock, so if long-running queries are reading from the table, the `DROP` waits for them to finish, and while it waits, every new query queues behind it. A 1ms metadata operation can block the table for minutes when a slow `SELECT` is running. **At scale**, the lock acquisition time becomes unpredictable, driven by whatever concurrent query workload happens to be running when the `DROP` fires. ## The Seedfast Workflow: Seed, Migrate, Measure The fix is to run your migration against production-scale data first. Seedfast generates realistic, relational data straight from your schema, so you can stand up a table at production row counts, without copying production rows or maintaining seed scripts, and time the migration against it. ### Step 1: Seed Production-Scale Data ```bash # Match your production table sizes seedfast seed --scope "seed 5 million orders with customers, order items, and payments" ``` The generated rows come out FK-valid and realistically distributed, with proportions that match the scope you asked for (batching and scope sizing for the very largest runs are covered in the [large-volume seeding guide](/docs/large-volume-seeding)): ```bash Seeding Plan: public.customers — 500,000 records public.orders — 5,000,000 records public.order_items — 12,000,000 records public.payments — 4,800,000 records Total: 22,300,000 records across 4 tables Approve? (Y/n) ``` ### Step 2: Run the Migration and Measure ```bash # Time the migration time psql $DATABASE_URL -f migrations/20260225_add_region_column.sql # Or with your migration framework time flyway migrate time rails db:migrate time alembic upgrade head ``` ### Step 3: Check for Lock Contention While the migration runs, open another terminal and monitor locks: ```sql -- See which queries are blocked and what's blocking them SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query, now() - blocked.query_start AS waiting_time FROM pg_stat_activity AS blocked JOIN pg_stat_activity AS blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid)); ``` Queries stacking up behind your migration are exactly what production will do, only with real user traffic behind them; PostgreSQL's [explicit locking documentation](https://www.postgresql.org/docs/current/explicit-locking.html) explains which lock modes conflict. ### Step 4: Benchmark the Safe Alternative If the naive migration locks the table for too long, implement the safer pattern and measure again: ```bash # Reseed a fresh database (or use a separate test database) seedfast seed --scope "seed 5 million orders with customers" # Run the batched migration time psql $DATABASE_URL -f migrations/20260225_add_region_column_safe.sql ``` Now you have concrete numbers your team can decide on: "The naive migration locks `orders` for 38 seconds. The batched version takes 2 minutes total but never holds a lock for more than 200ms." Run both candidate migrations against the same populated copy and you can see which lock pattern actually holds up ([the seeding guide](/docs/seed-your-database) covers a first run). ## CI/CD: Automated Migration Benchmarking Wire this into your pipeline so it stops being a manual exercise. ```yaml name: Migration Benchmark on: pull_request: paths: - 'migrations/**' jobs: benchmark-migration: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_DB: bench POSTGRES_USER: bench POSTGRES_PASSWORD: bench ports: - 5432:5432 steps: - uses: actions/checkout@v4 - name: Apply base schema run: | for f in migrations/*.sql; do psql $DATABASE_URL -f "$f" 2>/dev/null || true done env: DATABASE_URL: postgres://bench:bench@localhost:5432/bench - name: Seed test data run: seedfast seed --scope "seed 1 million orders with customers and payments" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} DATABASE_URL: postgres://bench:bench@localhost:5432/bench - name: Benchmark new migration run: | NEW_MIGRATIONS=$(git diff --name-only origin/main -- migrations/ | sort) for f in $NEW_MIGRATIONS; do echo "--- Benchmarking: $f ---" time psql $DATABASE_URL -f "$f" done env: DATABASE_URL: postgres://bench:bench@localhost:5432/bench - name: Check migration duration run: | echo "Review migration timing above. Migrations over 30s need batching or CONCURRENTLY." ``` Now every PR with a migration gets benchmarked against realistic data, so a 200-row staging pass no longer vouches for a change. For running it unattended, the [CI/CD database seeding guide](/docs/cicd-database-seeding) covers non-interactive mode and spinning up a database per PR. ## For Smaller Teams Even without full CI integration, you can add a one-liner to your PR template: ```bash ## Migration Checklist - [ ] Tested against production-scale data (`seedfast seed --scope "..."`) - [ ] Migration completes in under 30 seconds on 1M+ rows - [ ] No ACCESS EXCLUSIVE locks held for more than 5 seconds - [ ] Uses CONCURRENTLY for index creation (if applicable) - [ ] Backfills are batched (if applicable) ``` ## PostgreSQL Migration Gotchas Worth Knowing A slow migration is just one of [the bugs that only real test data catches](/blog/small-data-big-lies); the same volume blind spot behind slow queries and N+1s hides slow migrations too. A few more that trip teams up at scale: **Transaction-wrapped migrations lock longer than you think.** Most frameworks run each migration inside a transaction, which means the lock acquired at the start isn't released until the whole file finishes, backfills and constraint validations and index builds included. Because `CONCURRENTLY` can't run in a transaction, a framework that wraps migrations by default (Rails, Flyway, Alembic) makes `CREATE INDEX CONCURRENTLY` fail until you configure that specific migration to run outside the transaction block. For zero-downtime rewrites like a column-type change, `pg_repack` rebuilds a table in the background with minimal locking, though on a 50M-row table it still takes significant time and I/O, so test it against real volume first. **Autovacuum can fall behind after a large backfill.** The dead tuples an `UPDATE` leaves behind cause table bloat that slows subsequent queries if autovacuum can't keep up, so test the migration and then check bloat with `pg_stat_user_tables`. How much of that bloat lands in the indexes depends on the order the batches walk the table, which we measured on a million-row backfill in [what a Postgres backfill costs a large table](/blog/postgres-backfill-large-table). ## Before You Run That Migration in Production Checklist for any migration touching a table with more than 100K rows: - **Seed production-scale data locally** ```bash seedfast seed --scope "seed [your production row count] [table] with related records" ``` - **Time the migration.** If it takes more than 10 seconds, consider batching or alternative approaches. - **Monitor locks during execution.** An `ACCESS EXCLUSIVE` lock held for more than a few seconds will impact production traffic. - **Test the rollback too.** A 30-second migration with a 5-minute rollback is a risky deploy. - **Run it during low traffic** if the lock duration is unavoidable, and know exactly how long that is, in seconds. - **Check `CONCURRENTLY` support** in your migration framework for index operations. - **Validate constraints separately** using `NOT VALID` + `VALIDATE CONSTRAINT` for foreign keys and check constraints. - **Batch backfills** — never `UPDATE` millions of rows in a single statement. You can't measure lock duration on 50 rows, benchmark a backfill on an empty table, or catch `CONCURRENTLY` failing inside your framework's transaction wrapper without production-scale data. ## Frequently asked questions ### How do you test a database migration before running it in production? **You test a migration by running it against a database seeded with production-scale data and measuring how long it locks each table.** Stand up a database on the current schema, seed it to the same row counts as production, run the migration with a timer, and monitor `pg_locks` in a second session while it executes. Whatever lock duration shows up there is what production will hit, only with live traffic queuing behind it. Seedfast builds that seeded database from the same schema the migration targets, which is what makes the timing transferable. ### Why does a migration that runs fast in development lock the table in production? **Because most migration cost scales with row count, and a development database has almost no rows.** A backfill `UPDATE`, a non-concurrent `CREATE INDEX`, or an `integer`-to-`bigint` rewrite touches every row, so the same statement that finishes in under a millisecond on 50 dev rows can hold a lock for 40 seconds on 8 million production rows. ### Which PostgreSQL operations require a full table rewrite? **Changing a column to an incompatible type (such as `integer` to `bigint`), adding a column with a volatile default, and some `SET`/`DROP` operations rewrite the whole table under an `ACCESS EXCLUSIVE` lock.** Operations that are binary-coercible do not rewrite: widening a `varchar`, `varchar` to `text`, and, since PostgreSQL 11, adding a column with a constant default are metadata-only and instant at any size. ### How do you add an index to a large Postgres table without downtime? **Use `CREATE INDEX CONCURRENTLY`, which builds the index without blocking writes.** It takes roughly 2–3× longer than a plain `CREATE INDEX` and cannot run inside a transaction block, so most migration frameworks (Rails, Flyway, Alembic) need that specific migration configured to run outside their default transaction wrapper. A plain `CREATE INDEX` holds a `SHARE` lock that queues every write for the entire build. ### Can I use a copy of production data to test migration performance? **A production copy answers the volume question, but it drags real customer records (and their compliance exposure) into an environment with weaker controls.** By the time you refresh it, the schema has usually moved as well. Seeding the same row counts from the schema itself gives you identical lock behavior with nothing sensitive in the database. Seedfast works from your schema definitions rather than your rows, passing them to an external AI service to generate the data, so the thing to check is whether that schema handling fits your data-governance rules. ### How does Seedfast help test migrations at scale? **Seedfast gives you a database at production row counts to benchmark against, filling it with FK-valid rows generated from your live schema.** Because that schema gets read fresh on each run, a migration that changes the table doesn't invalidate the command that seeds it, so a benchmark stays repeatable across as many candidate migrations as you want to compare. Aim it at a local or throwaway CI database rather than production, and note that it works from schema structure and never from your rows. ## Related guides - [Review SQL Migrations in 30 Seconds](/blog/migration-review) — the data-shape half of the same check: catching NULLs, duplicates, and silent truncation at small volume during code review - [Load Testing Data: Why Your Benchmarks Lie](/blog/load-testing-data) — the other half of production-scale testing, for query latency and throughput rather than migration locks - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql) — the raw SQL patterns for generating volume and realistic distributions by hand - [Small Data, Big Lies: 6 Bugs Only Real Test Data Catches](/blog/small-data-big-lies) — why a 50-row development database hides the failures that only appear at scale - [Circular Foreign Key Seed](/blog/circular-foreign-key-seed) — handling FK cycles when you seed the large, related datasets a migration test needs - [Your Staging Database Is a Compliance Violation Waiting to Happen](/blog/staging-without-prod-data) — generating realistic test data instead of copying and anonymizing production - [Get started with Seedfast](/docs/seed-your-database) — connect your database and seed production-scale data for your next migration test ## Migration testing at production scale, before the deploy [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *Seed a test database to production row counts with Seedfast and time your next migration against volume that behaves like the real thing.* --- # Database Seed File Maintenance: Stop Patching seed.sql > Source: https://seedfa.st/blog/seed-file-maintenance > Last updated: 2026-07-22 _Why your team quietly stopped running seed.sql months ago. A practical guide for PostgreSQL, MySQL, and ORM-based projects, not torrent `.seed` files or BitTorrent seedboxes._ Your `seed.sql` was committed by someone who left two years ago. It worked for three weeks. Then a migration landed, and it has been quietly broken ever since. Maybe it's called `fixtures.sql`, `dev_data.sql`, or `testdata/init.sql`, but it's the same file, headed toward the same fate. This article is about **seed file maintenance** and answers why static seed files drift from the schema, what that drift costs in real engineering hours, how every mainstream ORM (Rails, Prisma, Django, Laravel) hits the same wall, and what to do once you stop pretending the file works. The short answer to _how to maintain seed files_ in 2026 is that you shouldn't try. [Seedfast](https://seedfa.st/) reads your live schema on every run and regenerates FK-valid data from a plain-English scope, so there is no static artifact to drift. If your team has a seed file that actually works on the current schema (without modifications, without commenting out lines, without someone saying "oh yeah, you have to run the migration first and then manually fix line 847"), you are in a vanishingly small minority, and that deserves congratulations. Everyone else is stuck with the file they all know is broken and nobody wants to fix, and this article is for them. > **TL;DR. Your `seed.sql` drifts because it's static.** Stop hand-editing a snapshot of last quarter's schema, and let Seedfast regenerate FK-valid data from a plain-English scope, matched against today's schema. Reference rows you assert on by literal value (admin accounts, country codes, feature flags) stay in a short hand-written `seed.sql`. Bulk development data gets regenerated. > > **[Try Seedfast free →](https://seedfa.st/)** to connect your DB and run the first seed in under five minutes. > > **If you're here because `npx prisma db seed` (or its Rails / Django / Laravel equivalent) just broke after a migration**, jump to [Try it on your seed file](#try-it-on-your-seed-file) for the install + run snippet. ## Key Takeaways - **Seed file maintenance is structural, not disciplinary.** A static file cannot keep up with a schema that changes every sprint. No amount of "be more careful" fixes that. - **Every ORM has the same problem in a different syntax.** Rails `db/seeds.rb`, Prisma `prisma/seed.ts`, Django fixtures, and Laravel seeders all break for the same reason, hard-coding the shape of your data. - **The real cost shows up in onboarding, CI, and developer trust.** A broken seed file turns a 2-minute setup into a 2-hour debugging session and teaches the team to distrust all shared data tooling. - **Replace the bulk-data portion of the file with Seedfast.** Instead of maintaining 500 lines of INSERT statements, describe the data you want in plain English; Seedfast regenerates matching rows straight from the current schema. Keep a small hand-written `seed.sql` for production reference rows and named-by-ID test fixtures. ## The Life and Death of seed.sql The lifecycle is so predictable it could be a template. **Week 1: Creation.** A motivated developer, usually someone onboarding, writes a seed file that inserts users, orders, and products, whatever the app needs to look populated. It works, the PR gets merged, and the team is grateful. Local development is smooth enough that people actually use the app locally instead of staring at empty states. ```sql -- seed.sql (v1, the golden age) INSERT INTO users (id, name, email, created_at) VALUES (1, 'Alice Johnson', 'alice@example.com', '2025-01-15'), (2, 'Bob Smith', 'bob@example.com', '2025-02-20'), (3, 'Carol Davis', 'carol@example.com', '2025-03-10'); INSERT INTO orders (id, user_id, total, status, created_at) VALUES (1, 1, 99.99, 'completed', '2025-01-20'), (2, 1, 149.50, 'completed', '2025-02-15'), (3, 2, 75.00, 'pending', '2025-03-01'); INSERT INTO products (id, name, price, category) VALUES (1, 'Widget Pro', 49.99, 'electronics'), (2, 'Gadget Plus', 29.99, 'electronics'), (3, 'Thingamajig', 19.99, 'accessories') ``` **Week 3: First crack.** A migration adds a `NOT NULL` column to `users`, but the seed file doesn't include it. New developers run the seed, get an error, and ask in Slack. Someone replies "oh just add `role DEFAULT 'user'` to the users table insert," but nobody updates the file. ```bash ERROR: null value in column "role" of relation "users" violates not-null constraint DETAIL: Failing row contains (1, Alice Johnson, alice@example.com, 2025-01-15, null) ``` **Month 2: The patch.** Someone gets frustrated enough to fix it, adding the missing column and noticing along the way that `orders` now has a `shipping_address_id` foreign key to a new `addresses` table, so they add an `addresses` insert block too. The PR is 200 lines of SQL changes for a file that was supposed to be "set and forget," and it passes review because nobody wants to think about it too hard. **Month 4: The second break.** The `products` table gets renamed to `catalog_items` as part of a domain modeling cleanup, but the seed file still references `products`. Someone opens an issue, and it sits in the backlog for six weeks because it isn't a production bug, only a developer-experience gap. **Month 6: The workaround.** The seed file has broken twice in two months, so a senior developer wraps it in a script: ```bash #!/bin/bash # run-seed.sh — "best effort" seeding set -e # just kidding psql $DATABASE_URL < seed.sql 2>/dev/null || echo "Seed had errors (this is normal)" ``` The `|| echo` is doing a lot of heavy lifting there. "This is normal" is doing even more. **Month 9: Abandonment.** The README still says "Run `./run-seed.sh` to populate your local database," but new developers who try it watch it fail silently on half the tables, ask in Slack, and hear back "I just use the staging database" or "I manually insert what I need." The seed file is effectively dead. It still exists in the repo, since deleting it would mean acknowledging the problem and fixing it would mean taking on ongoing commitment nobody wants, so it just sits there, a monument to good intentions. **Month 12: The zombie.** A new developer finds the seed file, spends two hours fixing it for the current schema, opens a PR, and the cycle begins again. ## Why Do Seed Files Always Drift From the Schema? **Your schema changes constantly; your seed file stays static.** That's the root of every drift problem below. Consider a typical sprint. One developer adds a `phone_number` column to `users`. Another creates a `user_preferences` table with a foreign key back to `users`, while a third changes `orders.status` from a text field to an enum type, whose values will need [a migration of their own](/tools/postgres-enum-migration-generator) the next time one of them is renamed, and a fourth adds a check constraint requiring `orders.total` to stay positive. Each of these changes is small, each migration gets tested, and each PR gets reviewed, but none of them touch the seed file, since it isn't anyone's job to. The seed file isn't part of the feature, it isn't in the test suite, and it isn't in the CI pipeline either, or if it once was, it got removed six months ago for breaking the build too often. The result is that the seed file drifts from the schema roughly in proportion to how fast your team ships schema-changing features, so the more productive the team is, the faster the seed file turns useless. [Seedfast](/docs/seed-your-database) flips that relationship, so schema velocity stops being the enemy, and the faster the team ships migrations, the less there is left to maintain. ## Nobody Owns It This is the human problem underneath the technical one. Who is responsible for `seed.sql`? The developer who wrote it moved to another team long ago; the developer who added the new column is too busy shipping features to maintain test infrastructure; the tech lead already has forty other things to worry about; and DevOps treats it as application-level data rather than infrastructure, so it isn't their job either. Seed files are communal property, and communal property is everyone's responsibility and therefore nobody's. The same thing that happens to shared kitchen spaces in offices happens to seed files in repos, a slow, inevitable decay until someone snaps and does a deep clean. Except with seed files, nobody snaps; they just route around the damage. ## Why Don't Migrations Update Seed Data Automatically? There's a deeper structural issue underneath this one. Migrations transform schema forward in time, while seed files stay frozen in the past, and your migration system knows how to get from schema version 47 to version 48 without having any idea how to update the test data that was valid at 47 into something still valid at 48. Some teams try to solve this by running seed files through the migration system, seeding at version 1 and then migrating up, which works fine until the first breaking migration, usually the third or fourth one, and from there you'd need to version your seed files alongside your migrations, maintaining parallel histories of schema changes and data changes that nobody keeps up for long. ## Seed File Maintenance Across ORMs: Same Problem, Different Syntax Switching to a "proper" ORM seeder does not make the maintenance problem go away; it just moves the work to a different file. Every mainstream stack hits the same wall: - **Rails (`db/seeds.rb`).** Use `find_or_create_by!` to stay [safe to re-run](/blog/database-seeding), split by model, keep seeds environment-aware. Organization improves; the columns are still hard-coded, so a `NOT NULL` migration still breaks `bin/rails db:seed`. - **Prisma (`prisma/seed.ts`).** Recent Prisma versions decoupled seeding from `prisma migrate dev` and `prisma migrate reset`, so you now run `npx prisma db seed` explicitly (Prisma seeding docs). Adding a required field still breaks `prisma.user.create({ data: { ... } })`, just at compile time instead of runtime, but the manual fix is the same. - **Django (fixtures vs factories).** `loaddata` fixtures break on most schema changes; `factory_boy` generates rows from the live model, which is why guides have been recommending factories over fixtures for a decade ([Caktus Group, 2013](https://www.caktusgroup.com/blog/2013/07/17/factory-boy-alternative-django-testing-fixtures/)). Factories help, but every new field is a patch to the factory code. - **Laravel (`DatabaseSeeder` + model factories).** `User::factory()->has(Post::factory()->count(3))->create()` is composable, until `posts` gains a `NOT NULL` column and every seeder throws a `QueryException`. The [Laravel docs](https://laravel.com/docs/seeding) recommend keeping seeders deterministic and in CI; that recommendation is what creates the PR backlog when schemas move. The pattern is the same across all four. **The syntax differs, but you are still writing a file that describes the shape of your data by hand.** Every schema change forces a corresponding edit. For a side-by-side comparison of these tools and seven other seeding approaches, see [Database Seeder: 7 Tools Compared](/blog/database-seeder). ## The Hidden Cost of Seed File Maintenance The seed file seems like a small thing, just a convenience file for local development, but a broken one does more damage than you'd think; seed file maintenance is the kind of work that doesn't show up in any sprint plan yet eats engineering time everywhere. ### Onboarding Delay A new developer joins the team, and the README says to clone the repo, run migrations, and run the seed file, but the seed file fails. Not knowing whether the failure is expected, whether their local setup is wrong, or whether they did something out of order, they spend an hour debugging before asking for help, and a senior developer spends another 30 minutes walking them through the workaround. Multiply this by every new developer, every quarter, then multiply again by the morale cost, since the new person's first experience with the codebase is discovering that the documented setup doesn't work, which is not a great first impression of your engineering culture. ### Broken Local Development Without working seed data, local development means staring at empty states. The dashboard flashes "No data found," list views sit empty, searches return nothing, and graph components render nothing but a flat line. Developers start creating data manually through the UI, which takes ten minutes every time they reset their database. Or they stop resetting their database, which means their local state diverges from everyone else's. Or they just develop against staging, which has its own problems (shared state, slow connections, risk of interfering with QA). The empty local database is a productivity drain that's hard to quantify because it's spread across every developer, every day, in small increments, five minutes spent creating a test user here, ten minutes spent setting up an order with the right status there, twenty minutes spent building the exact data configuration a new feature needs to test, and it all adds up to hours per developer, every week. ### CI Failures If your CI pipeline includes a seeding step (it should), a broken seed file means broken builds, and teams typically pick one of three options. **Fix the seed file every time it breaks.** This works, but it means someone is on permanent seed-file duty, patching SQL after every migration. **Remove the seeding step from CI.** This is what most teams actually do. The CI pipeline now tests against an empty database, which misses entire categories of [bugs that only surface at realistic data volumes](/blog/small-data-big-lies). **Make the seeding step non-fatal.** The `|| true` approach. The seed runs, fails halfway, inserts data into some tables but not others, and the test suite runs against an inconsistent partial dataset. This is arguably worse than an empty database, because the failures are intermittent and hard to diagnose. ### The "Just Comment It Out" Culture The most corrosive effect of a broken seed file is cultural. When developers learn that the seed file is unreliable, they develop a reflexive distrust of all shared data tooling. Suggestions to invest in better seeding infrastructure are met with "we tried that, it didn't work." Proposals for data-dependent integration tests are rejected with "those will just break when the seed file drifts." The broken seed file becomes a learned helplessness that prevents the team from investing in the thing they actually need. The way out is structural, not motivational. If your team has been burned enough times to distrust shared seed tooling, the fix is to stop shipping a tool that requires trust in the first place. Seedfast regenerates straight from the schema's current state instead of asking a human to keep a file in sync, so there's nothing left for the team to lose faith in. ## The 5-Minute seed.sql Health Check Before you decide what to do next, run a quick audit. This script spins up a fresh database, applies your migrations, runs your seed file, and reports exactly how broken it is. For deeper guidance on _writing_ a Postgres seed script that survives migrations, see [Postgres Seed Script: Build One That Lasts](/blog/postgres-seed-script). Copy this into `scripts/audit-seed.sh`. ```bash #!/usr/bin/env bash # audit-seed.sh — find the drift between seed.sql and your current schema. # Usage: ./audit-seed.sh path/to/seed.sql set -uo pipefail SEED_FILE="${1:-seed.sql}" DB="seed_audit_$(date +%s)" # 1. Fresh database, latest migrations applied. createdb "$DB" trap 'dropdb --if-exists "$DB"' EXIT # Replace with your project's migration runner: # Prisma: DATABASE_URL=postgres:///$DB npx prisma migrate deploy # Rails: DATABASE_URL=postgres:///$DB bin/rails db:migrate # Django: DATABASE_URL=postgres:///$DB python manage.py migrate # Raw SQL: psql "$DB" -v ON_ERROR_STOP=1 -f path/to/schema.sql # 2. Run the seed, capture every error (default ON_ERROR_STOP=off lets us collect them all). ERRORS=$(psql "$DB" -f "$SEED_FILE" 2>&1 \ | grep -E "ERROR|psql:.*: ERROR" || true) # 3. Check which tables ended up empty despite being referenced in the seed. REFERENCED_TABLES=$(grep -iE "INSERT INTO ([a-z_][a-z0-9_]*)" "$SEED_FILE" \ | sed -E 's/.*INSERT INTO ([a-z_][a-z0-9_]*).*/\1/' | sort -u) echo "=== seed.sql health report ===" echo if [[ -z "$ERRORS" ]]; then echo "No SQL errors." else echo "SQL errors found:" echo "$ERRORS" fi echo echo "Tables referenced by the seed file and their row counts after running:" for t in $REFERENCED_TABLES; do COUNT=$(psql "$DB" -Atc "SELECT count(*) FROM \"$t\"" 2>/dev/null || echo "MISSING") printf " %-30s %s\n" "$t" "$COUNT" done ``` Three things to look for in the output: 1. **Error lines.** Each one is a migration your seed file has not caught up with. 2. **Tables with 0 rows.** The `INSERT` succeeded syntactically but every row was rejected by a constraint you forgot about. 3. **`MISSING` tables.** The seed file references a table that no longer exists (it was renamed, merged, or dropped). Anything more than zero in any of those categories is drift. If this is your first time running the check, expect the report to be longer than you want it to be. ## What Keeping seed.sql Alive Actually Costs Your Team The strongest argument for a static seed file is "it's just a file, how expensive can it be?" Turn it into a number. **Maintenance cost per month, as a formula.** ``` cost = (minutes_per_break * breaks_per_migration * migrations_per_month) + (new_dev_onboarding_minutes * new_devs_per_month) + (minutes_per_dev_per_day_on_empty_db * devs * broken_seed_days_per_month) ``` These numbers are illustrative; flip any input toward your actual team and the total moves. For a 10-person team that ships ~12 migrations a month, hires one engineer a quarter, and has a seed file that breaks on roughly one in three migrations: | Input | Value | | --------------------------------------------------------------------------------- | ------ | | Minutes to diagnose + fix one break | 45 | | Break rate per migration | 0.33 | | Migrations per month | 12 | | Onboarding time lost to broken seed | 90 min | | New devs per month | 0.33 | | Minutes lost per dev per day to empty-db workarounds (on days the seed is broken) | 10 | | Broken-seed days per month (≈ one in three of 20 working days) | ~8 | | Developers | 10 | That comes out to roughly **~178 minutes fixing breaks + ~30 minutes onboarding friction + ~800 minutes of empty-database workarounds** per month, about 17 hours, more than two full engineering days, spent on a file that was supposed to save time. Both levers move that total. Reset the local DB less often, say twice a week instead of daily, and the dev-day-loss term shrinks; ship fewer schema-changing migrations and the break-rate term shrinks too. The exact figure matters less than the fact that it's never zero and that it scales with how much schema work your team ships. Once you have a number, "just keep the seed file up to date" stops looking like a discipline problem and starts looking like a tax. Seedfast turns that variable line item into a flat monthly plan carrying a pool of credits, and a full run on a 30-table schema draws about 60 cents of them. ## Seed File Maintenance Coping Mechanisms Teams develop creative ways to live with broken seed files, but all of them are worse than fixing the root cause, because seed file maintenance is structural, not behavioral, and these patterns route around the structure instead of changing it. ### The Optional Seed ```bash ## Local Setup 1. Run `make migrate` 2. (Optional) Run `make seed` to populate test data ``` When the seed step is "optional," it means "broken." Nobody makes a working tool optional, and you don't see `(Optional) Run the compiler` in setup docs; the word "optional" is a signal that the team knows the tool doesn't work reliably and has decided to make that someone else's problem. ### The Try-Catch Wrapper ```python # seed.py for table in ["users", "orders", "products", "categories"]: try: run_sql(f"seed_{table}.sql") except Exception as e: print(f"Warning: {table} seed failed ({e}), continuing...") ``` Every error gets swallowed, so half the tables succeed and half don't, and the developer has no way to know which half. The local database ends up with users but no orders, products but no categories, and the app technically runs even though half its features are untestable. Nobody investigates the warnings, because there are always warnings. ### The Versioned Seed ```bash seeds/ v1_initial.sql v2_add_roles.sql v3_add_addresses.sql v4_rename_products.sql v5_add_preferences.sql ``` This is the most disciplined approach, and it's also the most labor-intensive, since every migration that affects seeded tables requires a corresponding seed update. In practice, this means the developer writing the migration now has two files to update and test (the migration itself and the seed delta). The [Neon team's guide to maintaining seed data](https://neon.com/blog/how-to-maintain-seed-data) lays out this approach carefully (version the file, automate execution, keep it safe to re-run), and it is the best you can do with a static file. It also describes roughly one full-time responsibility you did not have before, and compliance drops rapidly after the first month. If your database is hosted on Neon, their branching feature offers an alternative path (instant schema-and-data snapshots per branch), but that approach is platform-specific and does not help the vast majority of teams running Postgres elsewhere. The platform-agnostic alternative is to stop versioning seed deltas entirely. Seedfast regenerates FK-valid rows from a scope string against the schema as it exists this sprint, so there is no `v6_*.sql` file to write next sprint and no parallel history to maintain. ### The Per-Developer Seed Eventually, developers start maintaining their own personal seed files, each tailored to the features they happen to be working on, none of them complete, and none of them compatible with anyone else's. The team now ends up with N different versions of local state, where N is the number of developers. "Works on my machine" takes on a new meaning when every machine has different data. The team-by-team divergence is the moment most teams realize the file isn't recoverable. Seedfast replaces the per-developer sprawl with one command that pulls directly from the schema in front of it, so every developer gets the same shape of data because the schema, not a hand-edited file, is the source of truth. ## The Fundamental Problem **All of these failures trace back to the same mismatch between bulk data that stays static and a schema that never stops moving.** A seed file is a snapshot, capturing the shape of your data at a single point in time, and the moment your schema evolves, which it does constantly because that's what healthy software projects do, the snapshot goes stale. This isn't a discipline problem, and no amount of "just keeping the seed file up to date" fixes it, any more than "just checking your watch more often" fixes clock drift. The problem is structural, since you're using a static artifact to describe a moving target. Instead of writing a better seed file, the fix is to regenerate the bulk data on each run from the schema itself. ## How Do You Maintain Seed Files Without Maintaining Them? What if your seeder read your current schema every time it ran? This isn't a file that was written six months ago, a snapshot that assumed the `products` table still exists, or a script that hardcodes column names; Seedfast reads the actual, current, live schema on each invocation, every column, constraint, foreign key, and enum that exists right now, at this moment. Describe what you want in plain English; Seedfast generates FK-valid rows that match the schema as it stands. This is the core idea behind [schema-aware test data generation](/blog/test-data-generation), and it's why nothing here is left for a human to keep in sync. Install once, then run it from any project directory. ```bash npm install -g seedfast # or: brew install seedfast-ai/tap/seedfast # or use it without installing: npx seedfast seed --scope "..." seedfast connect # paste your DATABASE_URL when prompted seedfast seed --scope "seed realistic data for all tables" ``` That's it. There's no SQL file to maintain for bulk development data, no new columns to add to the seed after migrations, and no foreign keys to wire up manually. Seedfast connects to your database, matches the schema's current shape, and generates data that fits. Reference rows your tests assert on by literal value (admin accounts, country codes, feature flags) stay in a small hand-written `seed.sql`; only the bulk development data gets regenerated. When a migration adds a `NOT NULL` column next week, Seedfast sees it the next time it runs; when a table gets renamed, it uses the new name; when a foreign key gets added, it fills the column with a valid reference on the next run; and when an enum type gains a new value, it shows up in the distribution too, because there's no static file sitting between the schema and the data to fall out of sync. ### Scope Instead of SQL Instead of writing SQL inserts, you describe what you need in plain English. ```bash # Instead of maintaining 500 lines of INSERT statements seedfast seed --scope "seed 1,000 users with orders, payments, and support tickets" ``` Seedfast checks your schema, proposes a plan, and seeds. The scope description works today and will work next month, because it references concepts ("users with orders") rather than column names `(user_id INTEGER NOT NULL REFERENCES users(id))`. When your schema changes, the same scope produces different data that matches the new schema, because the command and the intent stay the same; only the output adapts automatically. ### What This Looks Like in Practice **Before (the seed.sql lifecycle):** 1. Developer writes `seed.sql` (2 hours) 2. Works for 3 weeks 3. Migration breaks it (5 minutes to discover, 30 minutes to fix) 4. Works for 2 weeks 5. Another migration breaks it (someone files an issue) 6. Issue sits in backlog for 6 weeks 7. New developer fixes it (1 hour) 8. Works for 1 week 9. Two migrations land in the same sprint, seed file breaks in multiple places 10. Someone wraps it in `|| true` 11. Team stops using it 12. Repeat from step 7 every few months Cumulative time: dozens of hours per year. Effective uptime: maybe 40%. **After (seedfast):** ```bash # In your README seedfast seed --scope "seed realistic data for all tables" # In CI seedfast seed --scope "seed 1,000 users with orders" --output plain ``` There is no step 2 through 12. The command works after every migration because it lines up with the tables as they exist at that moment. There is no SQL file to patch and no error-swallowing wrapper script to maintain, so the seed-after-each-migration treadmill goes away. ### Does It Work on Real-World Schemas? Toy schemas with `users → orders → products` are easy. The interesting question is what happens when Seedfast meets the kind of schema that has been running in production for three years. Here's a short, honest support matrix: - **Foreign keys (single-column and composite):** Every row comes out valid and connected, with no manual insert ordering required on your side. Self-referential tables come out populated too, and a pair of tables that reference each other works wherever the schema leaves one side of the loop nullable. - **Enum types and check constraints:** Recognized and respected, with generated values drawn from the enum's members and passing the `CHECK` predicate. - **Generated columns (`GENERATED ALWAYS AS ...`):** Skipped on insert; the database computes them. Stored generated columns work the same way. - **Partitioned tables (range/list/hash):** Inserts go through the parent table; Postgres routes rows to the correct partition. No special configuration needed. - **`citext`, `jsonb`, `uuid`, `tsvector`, `numeric(10,2)`, geometric types:** Supported via Postgres's type system; Seedfast generates type-correct values. - **Triggers with side effects (audit rows, denormalized counts):** They fire as written. If a trigger does something you don't want during seeding, disable it for the seed run the same way you would for a `pg_restore`, using `ALTER TABLE ... DISABLE TRIGGER USER`, then run the seed and re-enable it afterward. - **Views, materialized views, and functions:** Not seeded directly (they don't hold data). Materialized views you may want to `REFRESH` after seeding. - **Multi-schema databases:** Tables in non-`public` schemas are read and seeded as long as your `DATABASE_URL` user has access. If your schema has something exotic that's not listed (extension-defined types, custom domains, row-level security policies that conflict with the seeding role), `seedfast doctor` will report what it can and can't handle before you commit to a run. The honest answer to "does this work on my real schema" is that in most cases it does, in some cases with one extra setup step, and `seedfast plan` will tell you exactly which before any rows are written. ### In CI/CD The seed file in CI is where the pain compounds, because CI failures block everyone. If you want the full walk-through for GitHub Actions and GitLab, the [CI/CD database seeding guide](/docs/cicd-database-seeding) covers exit codes and artifact patterns. Here's the short version. ```yaml # Before: fragile, breaks every few sprints - name: Seed test database run: psql $DATABASE_URL < seed.sql # fingers crossed # After: reads current schema every time - name: Seed test database run: seedfast seed --scope "seed 5,000 users with orders and payments" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: ${{ secrets.SEEDFAST_DSN }} ``` The `--scope` flag makes it non-interactive. To make the run idempotent, point each CI job at a fresh ephemeral database (e.g., a Postgres service container), since Seedfast appends rows rather than replacing them, so re-running against an already-populated database stacks more data on top. If you need a clean slate without recreating the DB, truncate the affected tables first. ### For Onboarding The before-and-after for new developers is dramatic. **Before:** Clone repo. Run migrations. Run seed. Seed fails. Ask Slack. Wait for response. Get workaround. Apply workaround. Half the data loads. Manually create the rest. Time: 1-3 hours. **After:** Clone repo. Run migrations. Run `seedfast seed`, review the proposed plan, hit Y. Time: typically 2 minutes on a small schema. There's no debugging, no Slack, no workarounds anymore. The database comes populated with realistic data that matches the current schema, and the new developer sees a working dashboard on day one instead of an empty state with a TODO comment. ## The Uncomfortable Truth About Seed File Maintenance Your `seed.sql` isn't broken because your team is lazy, but because the premise itself is flawed, since asking a static file to keep up with a dynamic schema means asking for perpetual maintenance, and perpetual maintenance of non-production tooling is exactly the kind of work that gets deprioritized, postponed, and eventually abandoned. The teams that have working seed files are the ones spending real engineering time on seed file maintenance, time that could be spent on features, on tests, on the product instead. Maintaining seed files is not a valuable use of engineering time; it's a tax you pay because the tool requires it. The answer to _how to maintain seed files_ in any production codebase, eventually, is to stop trying. At enterprise scale the math only gets worse. See [enterprise database test data](/blog/enterprise-database-test-data) for what compliance and volume add to the bill, and [staging without prod data](/blog/staging-without-prod-data) for why the regulated audience cannot just copy production into dev. Stop paying the tax by moving the bulk data out of `seed.sql`, keeping only the reference rows your tests assert on by literal value, and letting Seedfast regenerate the rest to match the schema you actually have. [Try Seedfast free →](https://seedfa.st/) ## Try it on your seed file If your `seed.sql` is the file nobody wants to own, point [Seedfast](/docs/seed-your-database) at the same database and let it generate the bulk data instead, matched to whichever tables are actually there when it runs. Reference rows your tests assert on by literal value, a handful of admin accounts or role names, stay in a short `seed.sql`. Everything else (the 500 users, 2,000 orders, the relational bulk that breaks every migration) gets regenerated on each run to match it. ```bash # Try without installing — runs the latest CLI in this shell only: npx seedfast connect npx seedfast seed --scope "1,000 users with orders, payments, and support tickets" # Or install once for repeated use: # npm install -g seedfast # brew install seedfast-ai/tap/seedfast ``` There's no file to update after the next migration and no factory code to keep in sync. The schema is the source of truth, and Seedfast checks it fresh on each invocation. **On privacy.** Seedfast's synthetic data generation does not require samples of your existing rows; the pipeline reads schema metadata (table and column definitions, types, FK relationships, constraints) and the scope text you provide. Your `DATABASE_URL` stays on your machine; the CLI connects to your database directly, and the password is never transmitted to Seedfast's servers. Note that `DEFAULT` expressions, `CHECK` constraints, and enum values are part of schema metadata and are transmitted as written; if any of those contain sensitive literals, treat them accordingly. See [how it works](/docs/how-it-works) for the full data-handling breakdown. The free plan covers connecting and running the first seed, and small schemas typically fit inside its monthly credits. See [pricing](/pricing) for current terms, or read the full walkthrough in the [getting started guide](/docs/seed-your-database). ## Frequently Asked Questions ### Why does my seed.sql break every time someone runs a migration? Because a seed file is a snapshot of the schema at the moment someone wrote it, while a migration is, by definition, a change to that schema, and any migration that adds a `NOT NULL` column, a foreign key, a check constraint, or renames a table will break any `INSERT` statement that doesn't already account for it. Rather than remembering to update the seed after every migration, the fix is to generate seed data from the current schema on every run, so there's nothing left to remember. ### What's the difference between a seed file and a migration? A migration changes the **shape** of your database (`CREATE TABLE`, `ALTER TABLE ADD COLUMN`, `DROP INDEX`), while a seed file inserts **rows** into the shape that migrations created. Migrations are versioned, ordered, and applied once per environment; seed data is rerun on demand. The two get conflated because both can be `.sql` files, but migrations answer "what does my schema look like?" while seeds answer "what data lives in it?" Mixing rows into migrations is a common anti-pattern; it makes migrations non-replayable across environments. ### How do I make my seed file idempotent (seed only once safely)? Idempotency means the second run produces the same final state as the first. Raw SQL reaches for `INSERT ... ON CONFLICT DO NOTHING` against a unique key, Rails uses `find_or_create_by!`, Prisma uses `upsert` with `where`/`create`/`update`, and Django has the `update_or_create` queryset method. None of these solve drift; they only protect against duplicate rows when the same script runs twice. A seed that is safe to re-run still breaks the day a migration adds a `NOT NULL` column the script doesn't know about. ### What changed in Prisma 7 — why was --skip-seed removed? Prisma 7 decoupled seeding from migration commands, so `prisma migrate dev` and `prisma migrate reset` no longer auto-run the seed, and the `--skip-seed` flag was retired along with it because there is no automatic seed run left to skip. To seed, you call `npx prisma db seed` explicitly (Prisma seeding docs). The change is cleaner (migrations and seeding are now separate concerns), but it does not fix the underlying drift problem. Your `prisma/seed.ts` still hard-codes column shapes, and a required field added to the model still breaks `db seed` until you patch the file. ### Can I just version my seed files alongside migrations? You can, and some teams do. This is the most disciplined manual approach, adding a corresponding seed delta for every migration that changes a seeded table, one that reshapes the existing fixtures to match. In practice, compliance collapses within a few weeks, because every PR now costs a second file to update, and reviewers stop catching it. It works for very small teams with slow-moving schemas, but it doesn't scale. ### What is the difference between fixtures, seed files, and factories? Fixtures and seed files are both static artifacts, JSON/YAML/SQL that describe specific rows and are loaded verbatim, and they share the same drift problem. Factories (Factory Bot, `factory_boy`, Laravel model factories) are code that _generates_ rows at test time using the current model definitions, so renaming a field is a compile-time error instead of a silent runtime failure. Factories are a clear upgrade over fixtures, but you still hand-write one per model, so schema changes still force edits, just in Ruby/Python/PHP instead of SQL. ### Does Prisma's or Rails' built-in seeding solve seed file maintenance? No. Prisma 7 actually removed automatic seeding from `prisma migrate dev` and `prisma migrate reset` (you now call `npx prisma db seed` explicitly), and Rails has always kept `db:seed` as a manual step. Both frameworks give you a nice place to put seed code; neither frees you from writing out the shape of your data by hand. Built-in seeders move where seed file maintenance happens, not whether it happens. See the [database seeding overview](/blog/database-seeding) for how ORM seeders fit into the broader picture. ### Is "just make the seed safe to re-run" enough? Safe-to-re-run (`find_or_create_by!`, `ON CONFLICT DO NOTHING`, `upsert`) is necessary but not sufficient. It prevents the _second_ run from failing; it does nothing about the first run failing because a new required column was added. You still need to edit the seed to match every schema change. Re-runnability is table stakes, not a solution. ### When should I still keep a hand-written seed file? Two cases keep seed file maintenance worthwhile. The first is **reference data that ships to production** (country codes, role names, default feature flags), which is part of your application's contract and belongs in migrations or a narrowly scoped seed step. The second is **specific named rows your tests assert on by ID**, a handful of rows clearly separated from bulk development data. Everything else (the 500 users, 2,000 orders, 10,000 line items you need for realistic local dev and CI) should be generated on demand from the current schema. ### How do I migrate from seed.sql to schema-aware generation without a big-bang rewrite? Leave the existing file in place. Add `seedfast seed --scope "..."` to the end of your setup script so new developers get schema-fresh data. Once everyone is on the new command, drop the `.sql` file. There's no flag day and no coordination needed; the two approaches coexist fine because they both write to the same tables. The [getting started guide](/docs/seed-your-database) walks through the first run. ### Does `seedfast seed` replace `npx prisma db seed`? It can, and for most teams it should. In `package.json` you can either swap the `"prisma": { "seed": "..." }` command to call `seedfast seed --scope "..."`, or leave `prisma db seed` for a small handful of hand-written reference rows and point developers at `seedfast seed` for bulk data. Seedfast connects to the same `DATABASE_URL` Prisma uses, so there is no separate connection config. The Seedfast CLI doesn't care which ORM you use; it works from the schema in the live database directly. ### What does Seedfast actually send to its AI provider — is my row data exposed? Nothing leaves your machine except the shape of your database and the words you type describing what you want seeded. On the schema side, that means table and column names, data types, nullability, length limits, foreign keys, and constraint definitions, plus your natural-language scope text; the AI provider generates synthetic rows from that shape, never from samples of your actual data. Your `DATABASE_URL` and its password never make the trip; the CLI uses them locally to connect to your database and nothing more. One caveat is worth knowing, though. Schema metadata includes `DEFAULT` expressions, `CHECK` constraints, and enum values exactly as written, so a sensitive literal buried in a default or a check constraint gets transmitted along with everything else, and the same goes for whatever you put in your scope text. For the complete breakdown, see [how it works](/docs/how-it-works). ## Related guides - [Getting Started](/docs/seed-your-database) walks through installing Seedfast and seeding your first database - [CI/CD Database Seeding](/docs/cicd-database-seeding) automates seeding so nobody maintains scripts manually - [Database Seeding: Methods and Best Practices](/blog/database-seeding) covers how seeding works across SQL, ORMs, and generators - [Database Seeder: 7 Tools Compared](/blog/database-seeder) picks the right seeder tool for your stack, with a quick syntax reference per ORM - [Supabase config.toml [db.seed] and sql_paths](/blog/supabase-db-seed) configures Supabase's seed pipeline through `supabase/config.toml` and the `[db.seed]` block, plus what happens on preview branches - [Test Data Generation](/blog/test-data-generation) explains schema-aware data generation that adapts to migrations - [E2E Test Fixtures](/blog/e2e-test-fixtures) shows how to replace JSON/YAML fixtures with generated data --- # Small Data, Big Lies: 6 Bugs Your Test Suite Will Never Catch > Source: https://seedfa.st/blog/small-data-big-lies > Last updated: 2026-07-22 A green test suite with 94% coverage carried this PR into production unquestioned, and two hours later the on-call phone lit up — a 47-second orders page, an export endpoint OOM-killing its pods, pagination skipping page 7. Between the suite and production the only real difference was the data, 2 million orders in one and 12 in the other. That gap is the **data volume blind spot**, and almost every team has one. ## Key Takeaways - Six bug classes stay invisible at 10 rows and become incidents at production scale: pagination off-by-one, N+1 queries, missing indexes, memory blowups, timeout cascades, and unique constraint collisions. - All six carry one signature, where passing tests hide a cost that climbs with row count until the bug surfaces in production. - The fix is to run the suite against realistic volumes before you deploy, turning each production incident into a failing test. - [Seedfast](https://seedfa.st/) populates your database with production-scale, foreign-key-valid rows, so your existing suite runs against realistic counts. ## The Volume Blind Spot Most test databases hold between 5 and 50 rows per table, fine for unit tests but enough to plant a dangerous assumption — that code passing at 10 rows will pass at 10 million. Small data hides whole categories of bugs that turn catastrophic at scale, and the sharpest version shows up [when an AI agent leaves the tables empty](/blog/ai-agent-empty-database) rather than merely thin. Here are the six that bite most often. ## 1. Pagination Off-by-One This is the one everybody has shipped at least once. A paginated API looks correct while one page of 10 rows covers the whole table. Push it to 10,001 rows and page 1001 comes back empty or duplicates page 1000, depending on whether the offset math uses `>` or `>=`. ```SQL -- Looks correct with small data SELECT * FROM orders ORDER BY created_at LIMIT 10 OFFSET ? -- At scale: duplicate rows when created_at isn't unique -- Rows shift between pages during concurrent inserts ``` The fix is usually [cursor-based (keyset) pagination](https://use-the-index-luke.com/no-offset), but the bug hides until you have enough rows to fill several pages and timestamps dense enough to collide. The same holds in [load testing with an empty database](/blog/load-testing-data), where the plan you measure at 50 rows is nothing like production's. ### How to catch it: ```bash seedfast seed --scope "seed 10,000 orders with timestamps" ``` Seedfast fills `orders` with ten thousand rows whose timestamps sit close enough together to collide, which is the exact condition the off-by-one needs. Page through to the end, compare the item tally against `SELECT COUNT(*)`, and a mismatch is the bug. ## 2. N+1 Queries An ORM loads a list of orders, and because each one lazily fetches its customer in a separate round-trip, the query count tracks the row count. A few orders cost a few queries nobody notices; a few thousand become a few thousand queries, and latency follows. ```bash # 5 rows: 6 queries, 80ms GET /api/orders → 200 OK (80ms) # 5,000 rows: 5,001 queries, 12,400ms GET /api/orders → 200 OK (12,400ms) # or timeout ``` ### How to catch it: ```bash seedfast seed --scope "seed 5,000 orders with customers and line items" ``` Enable query logging and count; any list view firing more than ~10 queries has a problem. ## 3. Missing Indexes Without an index, PostgreSQL scans 100 rows in well under a millisecond, so at development scale the query feels instant. The million-row version of that same scan runs for whole seconds, because the planner has no usable [index](https://www.postgresql.org/docs/current/indexes.html) and falls back to reading every row. ```SQL -- Fast at 100 rows (seq scan is fine) SELECT * FROM users WHERE email = 'john@example.com'; -- 1M rows: 800ms full table scan -- With index: 0.1ms ``` ### How to catch it: ```bash seedfast seed --scope "seed 100,000 users with realistic email addresses" ``` Then run [`EXPLAIN ANALYZE`](https://www.postgresql.org/docs/current/sql-explain.html) on your critical queries; any sequential scan on a table over 10K rows is a red flag. The [PostgreSQL test data cookbook](/blog/test-data-postgresql) has the SQL patterns for generating those volumes by hand. ## 4. Memory Blowups Loading a whole result set into memory costs a few kilobytes at 100 rows and sails through every test, right up until the same endpoint meets 100,000 rows in production, allocates 500MB, and gets OOM-killed mid-request. ```go // Loads ALL rows into memory rows, _ := db.Query("SELECT id, email FROM users") defer rows.Close() var allUsers []User for rows.Next() { var u User rows.Scan(&u.ID, &u.Email) allUsers = append(allUsers, u) // grows unbounded } ``` The same shape hides in export endpoints, report generators, batch jobs, and admin dashboards, anywhere code buffers a whole result set into an unbounded collection, harmless until production hands it enough rows to blow the heap. ### How to catch it: ```bash seedfast seed --scope "seed 100,000 users with profiles and activity logs" ``` Hit your export and report endpoints and watch memory; RSS climbing with row count means an unbounded query buffering the whole set. ## 5. Timeout Cascades Service A calls Service B, which queries the database; while the tables stay small the query returns in 5ms. Once the tables have grown, the same query needs three seconds, blowing past Service A's two-second timeout, so the retry piles onto an already-busy Service B, and the circuit breaker trips and the dashboard goes red. ```bash Small data: A → B (50ms) → DB (5ms) ✓ Large data: A → B (3.2s) → DB (2.8s) ✗ timeout A → B (retry) → DB ✗ timeout (DB now under double load) A → circuit breaker open ✗ cascade failure ``` A cascade like this needs volume, roughly 500,000 rows before one query is slow enough to breach a timeout and start the chain. ### How to catch it: ```bash seedfast seed --scope "seed 500,000 transactions with accounts and categories" ``` Run your integration suite and watch for requests nearing your timeout thresholds; one at 80% today is a timeout tomorrow as the table grows. ## 6. Unique Constraint Collisions Test fixtures lean on hand-picked values like `user1@test.com` and `user2@test.com`, which never collide because a person chose each one. Real signups don't cooperate, producing duplicates at scale when two users register the same normalized address, or when a batch import slips in near-duplicates that pass validation alone but violate the constraint together. ```SQL -- Works with 10 hand-crafted rows INSERT INTO users (email) VALUES ('user1@test.com'); -- Fails at 10,000 rows with realistic data distributions -- ERROR: duplicate key value violates unique constraint "users_email_key" ``` Generated data spreads across a realistic distribution, so it includes the near-collisions a hand-written fixture set never thinks to add. Ask Seedfast for 10,000 emails and your unique constraints get exercised against genuinely varied input, all while [foreign key relationships stay valid](/blog/referential-integrity). ### How to catch it: ```bash seedfast seed --scope "seed 10,000 users with realistic names and emails" ``` A collision that surfaces under 10,000 realistic rows is one production never gets to spring on you, and volume closer to production scale flushes out more of these edge cases early. ## The Pattern Line the six up and the same four-part fingerprint appears every time: - **Invisible at small scale** — test suite passes, code review looks fine - **Proportional to data volume** — gets worse as tables grow - **Discovered in production** — where the data is, and the users are - **Expensive to fix after the fact** — incident response, hotfixes, post-mortems The remedy never changes either, which is to **test against realistic data volumes before deploying.** ## Shift Left With One Command None of this needs a production copy, fixture factories hand-written for 50 tables, or SQL dumps that drift from the schema. ```bash # Seed production-scale data in your dev/staging database seedfast seed --scope "seed 100,000 users with orders, payments, and activity logs" ``` Point Seedfast at your database and it works out the right proportions across related tables, holding every foreign key valid. You describe the scope in plain English, review the plan, and approve: ```bash Seeding Plan: public.users — 100,000 records public.orders — 450,000 records public.payments — 320,000 records public.order_items — 1,200,000 records public.activity_logs — 2,000,000 records Total: 4,070,000 records across 5 tables Approve? (Y/n) ``` The free plan doesn't ask for a card, and when a run would outrun the credits on your [plan](/pricing), the CLI says so in the terminal before it writes anything. Seedfast touches only the tables in your scope and leaves the rows already there alone. ## In CI/CD Add a seeding step to your pipeline so the test suite runs against real data volumes on every PR: ```yaml - name: Run migrations run: npm run migrate env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - name: Seed test database run: seedfast seed --scope "seed 50,000 users with orders" --output plain env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} DATABASE_URL: ${{ secrets.DATABASE_URL }} - name: Run tests run: npm test env: DATABASE_URL: ${{ secrets.DATABASE_URL }} ``` With `SEEDFAST_API_KEY` set, the CLI runs non-interactively, so nothing pauses for a prompt in CI. Because a run only ever adds rows and never rewrites what a table already holds, repeating the same scope keeps growing the data. A clean repeat means starting empty, from a throwaway ephemeral database or freshly truncated target tables. The [CI/CD database seeding guide](/docs/cicd-database-seeding) covers the full pipeline, ephemeral per-PR databases included. ### Start Small, Then Scale You don't have to jump to a million rows. Start with enough to surface the first category of bugs: | Goal | Suggested scope | What it catches | | --- | --- | --- | | Pagination bugs | 1,000+ rows in paginated tables | Off-by-one, cursor issues | | N+1 queries | 500+ rows with relationships | Lazy loading performance | | Missing indexes | 50,000+ rows | Sequential scan bottlenecks | | Memory issues | 100,000+ rows | Unbounded collection growth | | Timeout cascades | 500,000+ rows | Cross-service timeout breaches | After the first bug, you'll want every PR run against realistic volumes as a habit. In our internal runs, Seedfast generated ~1M FK-valid rows on a 20-table SaaS schema in about 3.5 minutes. For the largest volumes, the [large-volume seeding guide](/docs/large-volume-seeding) covers batching and scope tuning. ## Frequently asked questions ### Why do bugs only appear with large amounts of test data? Because database behavior depends on how much data is in the tables, the same code takes different paths as it grows. The planner picks sequential scans on tiny tables and index scans on large ones, ORMs fan a relation into N+1 queries only once the list is long, and memory use tracks row count. At 10 rows none of that registers, so the defect waits until production supplies the volume that exposes it. ### How much test data do I need to catch performance bugs? Start at roughly 10× your current test volume and work up. Each bug class trips at its own threshold, all listed in the sizing table above. Watch for the point where latency stops scaling linearly with row count; that knee usually sits right on an architectural bottleneck. ### What is the N+1 query problem? It is the pattern where fetching a list of N records fires one follow-up query per record for a related row, so a single logical read becomes N+1 trips. Each query is fast, so the endpoint looks healthy until the list grows long, which is why thin test data hides it. ### Can I just copy production data into my test database instead? Copying production data drags PII and compliance exposure along with it. Under HIPAA, PCI-DSS, GDPR, or SOC 2, pulling real records into dev or CI collides with data-minimization and access-control rules, making it a compliance question before an engineering one. Exports also drift from the schema and break on the next migration. Generating from the schema gives you the same volumes with no real record in the pipeline. The schema shape itself (table and column names, types, constraints) does travel to an AI provider during generation, though row values never do, so teams whose naming alone is sensitive should weigh that path. ### How does Seedfast generate production-scale test data? You give Seedfast a plain-English scope and the volumes you want, and it produces connected relational rows with every foreign key valid on insert, then shows you the plan to approve first. Since it re-reads the schema on each run, a migration that would break a hand-written seed script gets picked up automatically. ## Related guides - [Load Testing With an Empty Database? Here's Your Problem](/blog/load-testing-data) — why an empty database makes every load-test number fiction - [PostgreSQL Test Data: A Syntax Cookbook](/blog/test-data-postgresql) — the raw SQL patterns for generating volumes and distributions by hand - [ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site](/blog/migration-testing) — the migration-at-scale cousin of these volume bugs - [Enterprise Database Test Data: What It Actually Looks Like](/blog/enterprise-database-test-data) — what realistic relational test data looks like across a large schema - [Get started with Seedfast](/docs/seed-your-database) — connect your database and run your first schema-aware seed ## Test against realistic data before you ship [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *Seedfast puts the volume into the database itself, working from your schema to decide how many rows each related table needs.* --- # Your Staging Database Is a Compliance Violation Waiting to Happen > Source: https://seedfa.st/blog/staging-without-prod-data > Last updated: 2026-09-10 *Why production data doesn't belong in staging, and how to run staging without production data by generating it fresh from your schema.* It's 9 AM Tuesday when your DPO stops by standup and asks, "Who has access to the staging database?" The honest answer is everyone — developers, QA, contractors, and CI all read the same three-week-old `pg_dump` of production, with real names, emails, payment history, and, in regulated shops, [medical records](/blog/test-data-for-healthcare) or financial transactions. In compliance terms, that box is a GDPR [Article 33](https://gdpr-info.eu/art-33-gdpr/) breach notification in waiting, unnoticed only because nobody files "it's just staging" as a breach. > **In short**, production data has no place in staging. Running staging without production data means building your staging rows from the schema itself, so no masked dump is involved. Point [Seedfast](https://seedfa.st/) at a database and it generates realistic, connected data at production volume without production access, so no PII reaches staging and the anonymization script goes away. ## The pg_dump + Anonymize Anti-Pattern Here's how most teams build staging environments with [`pg_dump`](https://www.postgresql.org/docs/current/app-pgdump.html): ```bash # Step 1: Dump production pg_dump production_db > prod_dump.sql # 47 GB, 3 hours # Step 2: Restore to staging psql staging_db < prod_dump.sql # another 2 hours # Step 3: "Anonymize" the sensitive columns psql staging_db -f anonymize.sql ``` And `anonymize.sql` looks something like this: ```SQL UPDATE users SET email = 'user' || id || '@example.com'; UPDATE users SET phone = '+1555000' || LPAD(id::text, 4, '0'); UPDATE users SET first_name = 'Test', last_name = 'User'; UPDATE payments SET card_last_four = '0000'; -- TODO: anonymize addresses (see ticket INFRA-2847, opened 8 months ago) -- TODO: handle the new medical_records table (added last sprint) ``` This pattern has four failure modes, and most teams are living at least two right now. ## 1. The Script Never Covers Everything That anonymization script was written six months ago, and the schema has kept moving since. Three PII-bearing tables have landed — `user_preferences` picked up location data, and `support_tickets` now holds free-text where customer names, account numbers, and even plaintext passwords get pasted in. Nobody has updated the script for them, and nobody will, which makes every "anonymized" refresh a quiet fiction. ## 2. Schema Drift Breaks the Restore Your production schema changes almost daily, so a Tuesday dump is already behind by Thursday, when a [migration](/blog/migration-testing) adds a `NOT NULL` column with no default and the restore falls over. Someone burns half a day tracing it, patching the dump by hand, and rerunning, only for the same break to return next week. ```SQL ERROR: column "verification_status" of relation "users" does not exist -- anonymize.sql references a column that was renamed to "kyc_status" last sprint ``` ## 3. The Dump Files Are Enormous A 50-million-row production database yields a dump measured in gigabytes; storing, transferring, and restoring it burns hours of infrastructure. Many teams refresh staging only weekly or monthly because anything faster is impractical. By the time that copy is a week old it has drifted from the source, missing relationships production has since formed and still carrying bugs you fixed upstream. ## 4. You're Probably Violating GDPR Right Now Under GDPR, processing personal data needs a lawful basis, and "we wanted realistic staging data" is not one. The regulation demands data minimization under [Article 5](https://gdpr-info.eu/art-5-gdpr/) and data protection by design under [Article 25](https://gdpr-info.eu/art-25-gdpr/), both of which a full production copy in a loosely governed environment undercuts. GDPR is not alone here, and CCPA, HIPAA, SOC 2, and PCI DSS all flag production data in non-production environments, so the copy that felt convenient is exactly what an auditor probes and a breach notice has to disclose. A [compliant test data tool](/blog/compliant-test-data) keeps personal data out by construction rather than after the fact — the generate-versus-mask breakdown makes that case well beyond staging. ## The Alternatives (And Why They Fall Short) ### Hand-Written Fixtures ```SQL INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@test.com'), (2, 'Bob', 'bob@test.com'), (3, 'Charlie', 'charlie@test.com') ``` Fixtures carry no PII, which is their appeal, but a few hand-written rows with identical timestamps and flat distributions stress nothing and fool no one. Staging becomes a ghost town the sales team can't demo on, its dashboard showing three users named Alice, Bob, and Charlie. ### Faker Libraries ```python from faker import Faker fake = Faker() for i in range(10000): db.execute( "INSERT INTO users (name, email, created_at) VALUES (%s, %s, %s)", (fake.name(), fake.email(), fake.date_time_this_year()) ) # Now do the same for orders... and order_items... and payments... # And make sure the foreign keys are valid... # And the status distributions are realistic... # And the timestamps are chronologically consistent ``` Faker hands you random values. Table ordering, foreign-key resolution, realistic distributions, volume proportions, all of that stays yours to build. On a 40-table schema that is a multi-week build that breaks on every schema change, like the anonymization script. That hand-wired work is exactly the split the [best AI test data generator](/blog/best-ai-test-data-generator) guide draws, and Seedfast sits on the side of it where you wire nothing, because every run reads the schema again before it writes a row. ### Seedfast ```bash seedfast seed --scope "seed 50,000 users with orders, payments, and support tickets" ``` Seedfast is a CLI that reads your schema and a plain-English scope, keeps every foreign key valid, and shapes value distributions to resemble production traffic. Because every row is generated from scratch, staging holds no production PII, and no script or dump file has to track the schema. One data path is worth knowing before you adopt it. Seedfast passes your schema's shape (table and column names, types, constraints) to an AI provider to generate the data, while the row values stay in your database. If those names are themselves sensitive, clear that call with your security policy the way you would any vendor. ## How to run staging without production data with Seedfast ### Describe What You Need You [describe the staging environment](/docs/scoping) you want, and Seedfast builds it from there: ```bash # Full staging environment seedfast seed --scope "seed 100,000 users with realistic profiles, 500,000 orders spread across the last 12 months, payments for each order, and support tickets for 5% of orders" ``` Seedfast analyzes your schema and proposes a plan: ```bash Seeding Plan: public.users — 100,000 records public.addresses — 95,000 records public.orders — 500,000 records public.order_items — 1,400,000 records public.payments — 500,000 records public.support_tickets — 25,000 records Total: 2,620,000 records across 6 tables Approve? (Y/n) ``` You never listed `addresses` in your scope; Seedfast added it because `orders` references it, then sized the line items to match, none of which you had to spell out. ### Automate It For scheduled or [CI pipeline](/docs/cicd-database-seeding) refreshes, set `SEEDFAST_API_KEY` to run the CLI non-interactively: ```bash # In your staging refresh script or CI pipeline export SEEDFAST_API_KEY="..." # from the Seedfast dashboard seedfast seed \ --scope "seed 100,000 users with orders and payments" \ --output plain ``` Exporting `SEEDFAST_API_KEY` drops the confirmation prompt, so the scope runs straight through and exits non-zero on failure. Seedfast appends to the tables in your scope without touching existing rows, so a second run stacks more data on top. For a repeatable refresh, point each run at a fresh database (an ephemeral Postgres container in CI) or truncate the target tables first: ```bash # Clean slate before each scheduled refresh psql "$DATABASE_URL" -c "TRUNCATE users, orders, payments RESTART IDENTITY CASCADE;" seedfast seed --scope "seed 100,000 users with orders and payments" --output plain ``` Three tables fit on one line, and past a few dozen, [the TRUNCATE statement generator](/tools/postgres-truncate-all-tables) will take a pasted schema and write the single statement that names them all. ### Scale It for Demos Sales demos need data that looks alive, hundreds of realistic profiles with enough activity to fill a dashboard: ```bash seedfast seed --scope "seed 5,000 users with varied subscription tiers, activity logs spread across the last 90 days, and a mix of active, churned, and trial accounts" ``` It's the same "must look alive, must hold no real customer data" brief [dedicated demo data](/blog/demo-data-generator) has to meet. ## Addressing the Concerns ### "But will the foreign keys be valid?" Foreign keys stay valid because Seedfast fills every reference with a row that exists, so joins hold. Schemas that reference themselves in a loop still load too, [circular references](/blog/circular-foreign-key-seed) included, with no hand-written insert needed to satisfy the cycle. ### "What about realistic distributions?" This is where Seedfast pulls away from Faker. Random output is uniform; Seedfast follows [realistic patterns](/docs/data-realism), so most orders land in "completed", timestamps cluster in business hours, and amounts trace a curve that looks like real buying. ### "Can I control the volume?" Volume comes from the scope, so ask for 1,000 users for a quick test and that is what you get; push it to 500,000 for a [load test](/blog/load-testing-data) and Seedfast scales up, cross-table proportions intact. ```bash # Quick staging refresh seedfast seed --scope "seed 1,000 users with orders" # Load testing seedfast seed --scope "seed 500,000 users with orders, payments, and activity logs" ``` For very high row counts, see the guide to [large-volume seeding](/docs/large-volume-seeding). ### "Is it idempotent?" Not on its own — each run adds to existing rows, so seeding twice leaves two copies. For a repeatable refresh, start empty, with a throwaway database per run or the target tables truncated first. ## The Real Comparison | | pg_dump + Anonymize | Fixtures | Faker Scripts | Seedfast | | --- | --- | --- | --- | --- | | Production PII risk | High | None | None | None | | Setup time | Hours | Days | Weeks | Minutes | | Schema changes | Breaks scripts | Breaks fixtures | Breaks generators | Adapts automatically | | Data realism | High (real data) | Low | Medium | High (AI patterns) | | Prod data in staging | Yes | No | No | No | | Maintenance | Ongoing | Ongoing | Ongoing | Zero | Tallied side by side, the trade-offs are stark. A `pg_dump` buys realism but drags along compliance exposure, gigabytes of infrastructure, and endless maintenance, while the fixtures-or-Faker route runs the same bargain in reverse, staying safe at the cost of realism and engineering time. Seedfast sits outside that trade entirely, generating rows that are realistic and PII-free with almost no upkeep. ## Frequently asked questions ### Is it a GDPR violation to use production data in staging? It is rarely illegal outright, but hard to defend. GDPR's lawful-basis and minimization duties both point away from parking a full production copy somewhere with looser access, and auditors log staging-side production data as a finding you will have to explain. ### How do you refresh a staging database without copying production? Generate it from your schema on demand, so nothing gets dumped or masked out of production. Pointed at a database connection, Seedfast writes fresh, connected rows that fit every table, so each refresh fills staging without a production record leaving home. ### Is synthetic data realistic enough for staging and demos? It is, once the rows honor your schema and the values follow real distributions rather than a flat random spread. Seedfast clusters timestamps, weights status fields toward common values, and keeps amounts in believable ranges, so a dashboard or demo reads as genuinely active. ### Does generated staging data keep valid foreign keys? Yes, every generated reference lands on a row that already exists, so joins never dangle. Schemas that loop back on themselves resolve too, provided one side of the loop is nullable or deferrable. ### Can staging data generation run automatically in CI? Yes, this is a standard unattended CI setup. With `SEEDFAST_API_KEY` set, the CLI skips the prompt and signals success or failure through its exit code. Since each run appends, start from an empty database, fresh or truncated. The same wiring behind [generating synthetic data in your CI/CD pipeline](/blog/synthetic-data-ci-cd) applies here, pointed at a staging refresh. ### What is synthetic staging data? Synthetic staging data is data built from your schema to populate a staging environment, standing in for a copied or masked production dump. Mirroring your tables and their realistic distributions while matching no real person, it carries no PII and stays clear of GDPR and SOC 2 scope. [Seedfast](https://seedfa.st/) produces it straight from the live schema, reading the tables at run time rather than replaying a file someone wrote months ago. For the wider tool set, see [data seeding tools](/blog/data-seeding-tools); for a Postgres comparison, [the best Postgres test data generator](/blog/best-postgres-test-data-generator). ## Getting Started Replace your staging refresh script with a single command: ```bash # Install curl -fsSL https://seedfa.st/install | sh # Connect to your staging database export DATABASE_URL="postgresql://user:pass@staging-db:5432/myapp" # Seed it seedfast seed --scope "seed 50,000 users with orders, payments, and support tickets" ``` Your staging database now holds realistic data with valid relationships and no production PII. A refresh like this finishes in minutes, and when the schema shifts next sprint you rerun the same command and skip the script edits. ## Bring up staging without the compliance risk [Get Started](/docs/seed-your-database) | [Documentation](/docs/welcome) | [Pricing](/pricing) *Seedfast fills staging with production-realistic data generated from your schema, matching real volume and patterns without ever pulling a customer record out of production.* ======================================================================== # Section: Compare ======================================================================== # Seedfast, an SDV alternative for filling an empty application database > Source: https://seedfa.st/compare/sdv-alternative > Last updated: 2026-09-10 The Synthetic Data Vault (SDV) is a Python library from DataCebo that learns statistical patterns from a real dataset and samples a synthetic one shaped like it. When the database in front of you is empty, that training step is the whole problem, and it is the usual reason someone starts looking for an SDV alternative. ## Who SDV fits SDV mostly fits data science work. You hold a table of real records, you cannot share it, and you want a stand-in with the same distributions to train or evaluate a model on. SDV Community installs with `pip install sdv`, has been developed on Python 3.9 to 3.14, and ships under the Business Source License 1.1 rather than a conventional open-source licence. ## Where schema-first seeding differs Seedfast starts from the schema rather than a dataset. Give it a PostgreSQL connection string and a sentence describing what the data should represent, one of the shapes in [scope examples](/docs/scope-examples), and it reads the tables and constraints as they stand before writing rows that satisfy them. Nothing has to be loaded afterwards either, which matters more than it sounds, because SDV's `sample()` returns pandas DataFrames and the insert is still yours to write. SDV does have a from-scratch path, and it sits behind a licence. DayZSynthesizer "produces synthetic data from scratch using the metadata," and the same page states the feature "is only available for licensed, enterprise users." ![Two starting points for filling a Postgres database: SDV trains on a real dataset you already hold and samples pandas DataFrames you still have to insert yourself, while Seedfast reads an empty schema's tables and constraints directly and writes the rows into Postgres](/blog/sdv-alternative-1.svg) | | SDV Community | Seedfast | | ------------------------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | | What it needs to start | Real training data plus a metadata object | A connection string and a sentence | | Runtime | Python 3.9 to 3.14, `pip install sdv` | A CLI, plus an MCP server for AI agents | | Where the output lands | pandas DataFrames you insert yourself | Rows written into your database | | Related tables | HMASynthesizer, "optimized for smaller datasets with around 5 tables and 1 level of depth" | One scope across every table it touches | | Generating with no source data | DayZSynthesizer, an SDV Enterprise feature | The ordinary path | | Licence | Business Source License 1.1, converting to MIT four years after release | Commercial; the CLI runs on your machine | | Price | Free to install; Enterprise by quote | $0 with $5 of credits a month, $16 with $32, $69 with $180 | ## Which SDV alternative fits your job Stay with SDV when you have real records, work in Python, and care that the synthetic copy holds the statistical shape of the original. Seedfast is the answer to a different question, the one you get when the Postgres schema is empty and an application has to boot against it today. We would rather say that plainly than stage a rivalry between two tools aimed at separate problems. ## Frequently asked questions ### Is SDV open source and free? SDV Community is free to install and its source is public, but the licence is not a conventional open-source one. The repository ships under Business Source License 1.1, with a grant that forbids offering the library's functionality as a synthetic data service, converting to MIT four years after each release. SDV Enterprise is priced by quote. ### Can SDV generate data without any real data? Yes, through DayZSynthesizer, which the docs describe as producing synthetic data from scratch with "no machine learning required." It is marked an SDV Enterprise feature for licensed users only. Everything in SDV Community fits on a real dataset first, so an empty schema needs either that Enterprise path or a schema-first tool. ### Does SDV write rows into a PostgreSQL database? The community edition does not, on its own. You call `sample()`, receive DataFrames, then handle the insert yourself in the right order. Connecting to a live database belongs to the Enterprise AI Connectors bundle. Seedfast writes into PostgreSQL over the connection string, which is what makes it one step in [CI/CD database seeding](/docs/cicd-database-seeding). ## Sources Each of these was read on 10 September 2026: - Welcome to the SDV - SDV Community, for the licence and Python versions - The SDV licence text - HMASynthesizer, for the five-table guidance - DayZSynthesizer, for the Enterprise notice - Integration overview, for training data and the AI Connectors bundle **Verified: September 2026.** SDV moves its page paths occasionally, so look up its current page before relying on a detail here. Two neighbours are worth a look. [Syntho alternative](/compare/syntho-alternative) covers the self-hosted UI platform that comes up in the same conversation, and the [test data tools comparison](/compare) puts every option in one matrix. To try the schema-first route on your own schema, the [free plan](/subscribe/free) asks for no card and [a first seed](/docs/seed-your-database) takes about two minutes. --- # Seedfast, a Syntho alternative for filling a Postgres schema from scratch > Source: https://seedfa.st/compare/syntho-alternative > Last updated: 2026-09-10 Syntho is a self-hosted synthetic data platform. You deploy it on your own infrastructure with Docker Compose or Kubernetes, point a workspace at a source database, then choose a generator for each column in its web interface. Anyone shopping for a Syntho alternative is usually after something narrower than the whole platform, which is a PostgreSQL schema full of coherent rows, produced by a command that also fits inside CI. > **Our take, September 2026.** Syntho suits an enterprise that has to keep every byte inside its own network and works across Oracle, DB2, Sybase and Databricks as well as Postgres. It is sold by quote, so budget conversations start with sales. If the job in front of you is a Postgres database that has to be populated before this afternoon's demo, [Seedfast](https://seedfa.st/) is the smaller purchase, being one CLI command against a connection string plus a scope written in plain language, on a free plan that asks for no card. ## What is Syntho, and what is it built for? Syntho's documentation is organised around one object, the **workspace**. A workspace holds a connection to a source database and, separately, to a destination database, and the generation job is configured table by table and column by column inside it. Their connector list is broad, covering Databricks, DB2 for LUW, Hive, Microsoft SQL Server, MySQL and MariaDB, Oracle, PostgreSQL and SAP Sybase, along with Azure Data Lake Storage, Amazon S3 and the local filesystem (docs.syntho.ai, index fetched 2026-09-10). Deployment is your own problem in the good sense, with Docker Compose and Kubernetes (Helm) guides published in full. The generator catalogue covers AI synthesize, Mock, Mask, Duplicate, free-text PII obfuscation, Exclude, calculated columns, key generators, JSON de-identification and consistent mapping. Two of those matter for anyone who wants to fill an empty database rather than de-identify a full one. Their FAQ says outright that "if you do not have any data yet, you can generate data from scratch to fill an empty database" (their FAQ, fetched 2026-09-10). Their key generators page adds that the **Generate** method "can also be used when creating data from scratch," while Duplicate and Hash both assume real keys already exist to copy or obscure. So the two tools do overlap, though less than a shared category label suggests. The practical split is where a run's specification comes from. Syntho has a person supply it by working through the column settings of every table in the workspace, a design that earns its keep when each column needs its own treatment for privacy reasons and starts to weigh once the schema is wide. Seedfast reads the schema and takes one sentence. ![Where a run is specified in each tool, the practical difference behind any Syntho alternative for Postgres: per-column generator, range and key settings inside a Syntho workspace, against one Seedfast scope naming row counts, status shares and locale](/blog/syntho-alternative-1.svg) ## Syntho vs Seedfast: the capability matrix Every Syntho cell below links to the page it came from, and each was re-read on 10 September 2026. We have marked the rows where Syntho is the better answer. Seedfast prices come from [seedfa.st/pricing](https://seedfa.st/pricing). | What you are doing | Syntho | Seedfast | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Where a run is specified | Web UI. Open the workspace, select a column, then Column parameters > Generator (AI synthesize) | One CLI command carrying a scope in plain language | | Written instruction describing what the data should mean | No prompt surface in the public docs | Yes, and the scope may be written in any human language ([localized test data](/docs/localized-test-data)) | | Runs as a CI step | Not documented; the docs index lists no CLI page (docs.syntho.ai/llms.txt, 2026-09-10) | Yes, one step after migrations ([CI/CD seeding](/docs/cicd-database-seeding)) | | Driven by an AI coding agent | No MCP server in the public docs | Yes, native MCP for Claude Code, Cursor and other MCP clients ([setup](/docs/mcp-setup-guide)) | | **Database engines** _(Syntho covers more)_ | **Postgres, MySQL/MariaDB, MSSQL, Oracle, DB2, Hive, Databricks, SAP Sybase, S3, ADLS, files** | Postgres today; other engines in development | | **Runs entirely inside your network** _(Syntho covers this)_ | **Yes, self-hosted via Docker Compose or Helm** | CLI runs on your machine against your database; planning is a hosted service | | Multi-table generation from one instruction | AI synthesize is single-table; its docs list "multi-table logic, joins, or cross-table consistency" under _When not to use_. Multi-table work goes through per-column Mock, Mask and key generators | One scope covers every table it touches, and each reference lands on a row that exists, [circular references included](/blog/circular-foreign-key-seed) | | Numeric shape in generated (non-learned) values | Uniform (min inclusive, max exclusive), numeric series (start, step), custom integer pattern (numeric integer) | Named in the scope, including status shares such as 60% paid, 25% overdue, 15% disputed ([exact row counts](/docs/exact-row-counts)) | | Exact row count per table | Subsetting is documented as approximate: "Although the user can select 5% of the target table, that does not mean the final database will contain 5% of all data" (subsetting) | Write the number per table into the scope; a published run planned 4,703 records across 8 tables and finished 8/8 in 79.59s | | Language of generated values | 80+ language and region rows, set as the workspace's configured language, falling back to English (United States) (supported languages) | Named per table or per column inside the same sentence that asks for the rows | | Time to the first row | Deploy the platform, create a workspace, configure the columns | `npm i -g seedfast`, log in, run one command | | Price | No public list price. Basic, Standard and Ultimate all show _Get a quote_ (syntho.ai/pricing, 2026-09-10) | Free $0 with $5 of credits monthly, Basic $16 with $32, Premium $69 with $180 | Two of those rows go to Syntho outright, and they are not small ones. A bank that cannot let a schema leave its perimeter has a genuine reason to run a platform it hosts itself, and so does a team whose tables sit in DB2 and Sybase rather than Postgres. ## Where the two tools actually diverge ### The configuration surface Syntho's configuration lives in dropdowns. Their AI synthesize page walks through it in numbered steps, opening a workspace, selecting a column, then setting the generator under Column parameters, and every other generator page follows the same shape. Nothing in the public documentation accepts a written description of the data you want, and their pattern feature uses a small placeholder syntax rather than prose, so `?????-########` is how you ask for a product number. That difference compounds with schema width. Forty tables at eight columns each is 320 decisions in a UI, and a migration next sprint reopens some of them. Seedfast takes one sentence instead, and reads the schema fresh on every run so the sentence keeps working after the migration lands: ```bash seedfast seed --scope "Seed the crm and billing schemas: 200 customers, 600 contacts, 400 addresses, 3 plans, 300 subscriptions, 1,200 invoices with 60% paid, 25% overdue and 15% disputed, 400 support tickets and 1,600 ticket messages." ``` ### One instruction across related tables Syntho documents this boundary honestly, which is worth saying plainly rather than turning into a jab. Their AI generator page carries a _When not to use_ list, and the first item on it is "You need multi-table logic, joins, or cross-table consistency." The same page adds that AI synthesize "does not guarantee full relational logic, exact edge cases, or new signal that is absent from the source," and their FAQ puts it in one line: "AI synthesis only works well on single tables." Relationships in Syntho are handled by a separate layer, the key generators, configured per key column and chosen from Duplicate, Generate or Hash. Because Seedfast treats the whole scope as one job, asking for customers, contacts, subscriptions and invoices together gives you invoices that each point at a subscription that exists, with no per-key setting anywhere. Circular references work the same way, including a cycle that is `NOT NULL` at both ends with no deferral, so a table that loops into another table needs no separate key generator to come out valid. ![How Syntho and Seedfast handle related tables in a Postgres schema: Syntho's AI synthesize step works one table at a time, so relationships go through separate Duplicate, Generate or Hash key generators chosen per key column, while a single Seedfast scope covers every table and each reference resolves against a row that already exists](/blog/syntho-alternative-2.svg) ### Counts, shares and locale Their subsetting page carries the most candid sentence on either vendor's site, and it deserves quoting rather than paraphrase. "Although the user can select 5% of the target table, that does not mean the final database will contain 5% of all data." The reason they give is referential integrity, since keeping linked rows valid drags more data along than the percentage implies. That page also notes subsetting "is not parallelizable by nature," so a large database can take "hours, or even a full day." On the Seedfast side a count is part of the sentence you already wrote. The [exact row counts guide](/docs/exact-row-counts) publishes three runs of the scope above against a fresh schema, each verified with SQL afterwards, and the tables that carried a number came out carrying that number. Locale behaves the same way, as a clause rather than a setting. Where Syntho sets language once per workspace and falls back to English (United States) if a mock generator lacks it, which is a sensible design for a platform that de-identifies one database at a time, a single Seedfast run can put Arabic names in one table and German addresses in another. The [localized test data guide](/docs/localized-test-data) shows both, along with six runs where the instruction itself was written in Slovak. ### What a quote hides Syntho's pricing page describes the model as "feature-based pricing, no consumption-based charges," which reads well, and then every package button says _Get a quote_. Implementation is packaged separately as Small, Medium or Large, also by quote. None of that is unusual for a self-hosted enterprise platform, and it does mean you cannot compare the two tools on price without a sales call on one side of the table. Nothing is hidden on our side of that table. The free plan costs nothing, refills $5 of credits every month and asks for no card, with $10 instead of $5 on a work-email signup, while Basic is $16 a month for $32 of credits and Premium is $69 for $180. Credits are spent by how much data a run generates rather than by row or by token, and a full session on a 30-table schema comes to roughly $0.60, which is where the eight, fifty and three hundred runs a month on the [pricing page](/pricing) come from. ## Which Syntho alternative fits your job? Syntho is the better answer whenever the binding constraint is where the software runs. If the data cannot leave your network, or the tables you care about live in Oracle, DB2 or Sybase, or the real task turns out to be de-identifying a production copy rather than replacing it, then a platform you host yourself settles a compliance question that no CLI ever will, and the quote is what that costs. The other case, and the one we built for, is a PostgreSQL database that has to hold coherent data by this afternoon. One command reads the live schema, the sentence you wrote says what the data should represent, and the rows land satisfying the keys and constraints already defined there. Check that against your own schema on the [free plan](/subscribe/free), or read [how a first seed goes](/docs/seed-your-database) before installing anything. Cross-shopping wider is worth the half hour. [Tonic.ai alternatives](/compare/tonic-ai-alternatives) splits that vendor's three jobs into three shortlists, [SDV](/compare/sdv-alternative) is the Python route for teams that already hold real data to learn from, and [Mockaroo](/compare/mockaroo-alternative) reaches a flat CSV fastest when relationships are not the problem. The [test data tools comparison](/compare) hub lines all of them up in one matrix. ## Frequently asked questions ### How much does Syntho cost? Syntho does not publish a price. Its pricing page lists three packages, Basic, Standard and Ultimate, and each carries a _Get a quote_ button rather than a figure, with implementation deployment packages sold separately as Small, Medium or Large. The page describes the model as "feature-based pricing, no consumption-based charges" (verified 10 September 2026 at syntho.ai/pricing; check it before budgeting, since vendor pages move). Seedfast publishes its ladder instead, $0 a month with $5 of credits, $16 with $32 and $69 with $180. ### Does Syntho need existing data to generate? It does not need one for every generator. Syntho's FAQ says directly that "if you do not have any data yet, you can generate data from scratch to fill an empty database," and their key generators page notes that the Generate method also works when creating data from scratch. AI synthesize is the part that learns from a source table, and it is documented as single-table only. Seedfast never needs a source dataset, because the schema is the input. ### Can Syntho run in a CI pipeline? Nothing in Syntho's public documentation describes a command-line interface or an MCP server, and their published docs index carried no such page when we read it on 10 September 2026. Jobs are configured and started inside the workspace UI. Seedfast was built the other way round, as a CLI that takes a connection string, which is what makes it a single step after migrations in [CI/CD database seeding](/docs/cicd-database-seeding) and a tool an AI agent can call over MCP. ### Is Syntho or Seedfast better for a PostgreSQL database? Both connect to PostgreSQL, so the answer turns on what else is true. Syntho earns its place when the environment is regulated enough that the platform must run inside your own network, or when Postgres is one engine among several you have to cover. We think Seedfast fits better once Postgres is the whole story, the run has to happen from a terminal or a pipeline rather than a browser tab, and writing one sentence appeals more than configuring several hundred columns. ### Does Syntho keep foreign keys valid? Yes, and its documentation explains how the choice is exposed. Referential integrity in Syntho is handled by three key generators, Duplicate (copy the original key values), Generate (create new keys, preserving references but not correlations) and Hash (obscure the values while keeping both). Each is selected per key column. Seedfast keeps references valid without that per-column step, and handles [circular references](/blog/circular-foreign-key-seed) as well, including cycles that are `NOT NULL` on both sides. ### What can Syntho do that Seedfast cannot? Syntho connects to database engines Seedfast does not support yet, Oracle, DB2, SAP Sybase, Hive and Databricks among them, and the whole platform deploys inside your own infrastructure through Docker Compose or Kubernetes, so a schema never crosses the perimeter. It also de-identifies existing production data using a PII scanner, masking generators and a privacy audit report. That last one is a job Seedfast does not do at all, by design, since we generate rows rather than transform real ones. ## Sources Every Syntho claim on this page was read on the vendor's own pages on 10 September 2026: - docs.syntho.ai and docs.syntho.ai/llms.txt for the documentation index, connector list and deployment sections - AI synthesize for the UI configuration steps and the _When not to use_ list - Frequently asked questions for generating into an empty database, single-table AI synthesis and calculated columns - Numeric integer mock generators for the uniform, series and custom-integer options - Supported languages for language coverage, the workspace-level setting and the English (US) fallback - Referential integrity and foreign keys for the Duplicate, Generate and Hash key generators - What is subsetting for the 5% caveat and the runtime note - syntho.ai/pricing for the three quote-based packages Seedfast figures come from our own [pricing page](/pricing) and from two published guides, [exact row counts](/docs/exact-row-counts) and [localized test data](/docs/localized-test-data), each of which prints the CLI transcript and the verification SQL for the runs it describes. **Verified: September 2026.** Syntho's documentation is actively maintained and its page paths have moved before, so treat every quote here as a snapshot of that date and check the vendor's own page if a detail is load-bearing for you. ## Seed the schema you already have Most teams will decide this on one thing, how much has to be standing up before the first row exists. [Seedfast](https://seedfa.st/) needs a connection string and a sentence. Rows arrive satisfying the keys and constraints your schema already defines, so nothing is orphaned and there is nothing to reconcile once the run finishes. Finding that out costs about two minutes on [your first seed](/docs/seed-your-database), because the free plan refills $5 of credits every month and never asks for a card. [What one command does](/#demo) takes less time still. Related guides: - [SDV Alternative](/compare/sdv-alternative) for the open-source Python route - [Tonic.ai Alternatives](/compare/tonic-ai-alternatives) for masking, ML synthesis and seeding as three separate shortlists - [Mockaroo Alternative](/compare/mockaroo-alternative) for flat exports against live-database seeding - [Test Data Tools Comparison](/compare) for the full matrix --- # Tonic.ai alternatives, including where Seedfast fits and where it does not > Source: https://seedfa.st/compare/tonic-ai-alternatives > Last updated: 2026-09-10 Search "Tonic.ai alternatives" today and you mostly get directory pages that stack twenty tools in a grid with no sense of which one replaces what. Those lists feel useless because Tonic.ai is not one product. It sells de-identification through Tonic Structural, unstructured-data redaction through Tonic Textual, from-scratch synthetic data through Tonic Fabricate, and bespoke AI-training datasets through Tonic Datasets, which launched in August 2025 (tonic.ai, product lineup as of July 2026). So "an alternative to Tonic" is really three separate questions sharing one search box, and the right answer depends entirely on which product brought you here. Rather than rank a crowded field, we sort it by the job you're actually doing, naming the credible options for each and saying plainly where our own tool, Seedfast, has no business being on your shortlist. Two of the three jobs below are not ones Seedfast does, and for both we hand you off to the tools that do. ## Tonic.ai alternatives, sorted by the job you're doing Find your job in the list, then read that job's section below. Everything here is current as of July 2026, and each vendor's own page is named the first time a figure appears. - **De-identifying and subsetting production data** — the Tonic Structural job. The credible replacements are Delphix, K2view, Datprof, and open-source Greenmask; Neosync also comes up, though its 2025 archival changes that conversation. Seedfast is not built for this. - **Synthetic data for ML training or privacy synthesis** — sold through Tonic Fabricate, Tonic Datasets, and Tonic Textual. Look at SDV, YData, MOSTLY AI, or NVIDIA NeMo. Seedfast is not built for this either. - **Populating an application database for development, testing, staging, or demos** — the seeding slice of Fabricate usage, and the one place Seedfast fits. Tonic Fabricate stays a strong pick here too, especially for in-browser, exploratory generation. Name the product you're leaving first and you skip a directory of tools built for a different problem. ![Tonic.ai alternatives grouped by job as of July 2026 — de-identify production data via Delphix, K2view, Datprof, or open-source Greenmask; ML-training synthesis via SDV, YData, MOSTLY AI, or NVIDIA NeMo; application-database seeding via Seedfast](/blog/tonic-ai-alternatives-1.svg) ## Job 1 — de-identifying production data at enterprise scale This is the job Tonic Structural exists for — take a production database, mask the personal data, subset it down to a workable size, and provision a safe copy into lower environments. Structural's pricing stays behind a sales quote rather than a public self-serve tier (tonic.ai/pricing, as of July 2026). If it already fits your procurement and passes your compliance review, there's a fair case for staying put; the options below are for teams who want a different vendor, a different price shape, or an open-source path. Seedfast is the wrong category here — it generates fresh rows from a schema and never touches production data — so we leave it out of this section entirely. **Delphix (Perforce).** Now part of Perforce, Delphix pairs data virtualization with masking, making lightweight virtual copies of large databases and de-identifying them on the way to dev and test. It's the heavyweight of this tier, aimed at enterprises with many downstream environments and a lot of data to move around, and getting a price means a sales conversation rather than a self-serve checkout (delphix.com, as of July 2026). **K2view.** For teams that think in business entities rather than tables, K2view organizes data around a micro-database per customer or order, then masks and provisions those entities on demand. It's entity-based [test data management](/blog/test-data-management) sold into large enterprises, priced only after a sales conversation like the rest of this tier (k2view.com, as of July 2026). **Datprof.** Datprof aims a rung down the market from Delphix and K2view — mid-market masking and subsetting with a lighter footprint and a quicker setup for teams that found the enterprise suites heavier than the job needed, and the pricing here follows the same quote-based script (datprof.com, as of July 2026). **Greenmask.** If you'd rather stay open-source and you're on Postgres, Greenmask is the active pick. It replaces `pg_dump` with a masked equivalent, so you connect to production, declare a transformer per column, and get a sanitized dump that loads straight into staging (greenmask.io, open-source, as of July 2026). Structural's core masking is covered here without an enterprise contract, though environment provisioning and orchestration — what the commercial suites hand you outright — becomes your job instead. **Neosync.** Neosync used to be the open-source pick for combined masking and sync, but it reads as a cautionary footnote now rather than a live option — the project was acquired and its repository archived in 2025. We wrote up where its users are landing in the [Neosync alternative guide](/compare/neosync-alternative); for the masking half of what it did, Greenmask above is the closest actively maintained replacement. ## Job 2 — synthetic data for ML training and privacy synthesis The second question hiding inside "alternative to Tonic" is about synthesis for models, meaning data that reproduces the statistical shape and rare cases of a real dataset well enough to train, fine-tune, or evaluate on, without carrying real records forward. Tonic sells this through Fabricate and Tonic Datasets (bespoke training datasets, launched August 2025), plus Textual for unstructured text. Judged against this job, Seedfast comes up empty — it's built to make a schema boot, not to reproduce a dataset's statistical shape, and putting it on an ML shortlist would cost you time rather than save it. Here's where we'd look instead. **SDV (Synthetic Data Vault).** SDV is the open-source anchor of this list, an MIT-licensed Python library for tabular, relational, and time-series synthesis that you self-host and run in a notebook (sdv.dev, as of July 2026). You give up a managed platform and take on the operational burden yourself, which is the right trade when you have engineering time and a synthesis task you already understand. **YData.** Closest to a notebook-first workflow is YData, which keeps an open core (the `ydata-synthetic` lineage) beneath a commercial platform called Fabric for teams that want the managed version (ydata.ai, as of July 2026). If living inside a Python environment was what you liked, this is the least jarring move. **MOSTLY AI.** On the commercial, fidelity-first end sits MOSTLY AI, which pairs a managed platform for tabular synthesis with an open-source Synthetic Data SDK (mostly.ai, as of July 2026), letting you prototype in code before deciding whether the hosted product earns its keep. It's the one to price when a vendor relationship matters more to you than self-hosting. **NVIDIA NeMo (Data Designer and Safe Synthesizer).** The technology formerly sold as Gretel now ships inside NVIDIA NeMo as the Data Designer and Safe Synthesizer microservices, distributed through NVIDIA AI Enterprise with sales-gated pricing (nvidia.com/en-us/ai-data-science/products/nemo, as of July 2026). If you arrived here after Gretel's self-serve tier disappeared, our [Gretel alternative guide](/compare/gretel-alternative) maps that migration in full. ## Job 3 — populating an application database (where Seedfast fits) The third job is the one people don't always realize is separate. A lot of Fabricate usage is really database seeding — someone needs a dev, test, staging, or demo database full of connected rows, and a synthetic-data tool happened to be the thing already open. Statistical fidelity isn't the standard here — what matters is whether the data holds together, every foreign key resolving to a row that exists, unique constraints intact, NOT NULL columns filled, so the application boots on the first insert instead of throwing. [Seedfast](https://seedfa.st/) is built around exactly that answer. Point it at your database, describe the scenario in plain English, and Seedfast reads the live schema on that run and generates a connected dataset satisfying the constraints it finds, with no relationships left for you to wire up. Seedfast requires no production access to do it, since the rows are generated rather than copied from a real dataset. Because one CLI command is the whole surface, Seedfast drops into CI right after your migrations, and the same run is exposed as an MCP tool so an AI coding agent in Claude Code or Cursor can seed a database inside the conversation. Seedfast charges a monthly plan that includes a pool of credits, $16 a month for $32 of credits or $69 for $180, and under both there is a free plan that takes no card and puts back $5 of credits a month. ```bash seedfast seed --scope "a b2b billing app with 80 customers, invoices in three currencies, and an overdue-payment queue" ``` Two honest concessions keep this fair. Seedfast is Postgres-first today, whereas Fabricate generates into Postgres, MySQL, Oracle, and Databricks, so when your seeding spans engines Fabricate covers ground Seedfast doesn't. And Fabricate's interactive web-and-chat surface is genuinely good for poking at a dataset in a browser, which a headless CLI isn't trying to be. Fabricate's published pricing reflects that interactive model — a free plan whose credits vary by signup email ($5 a month of credits with a restricted model set on a personal email, $10 with all available models on a work email), a Plus plan at $29 a month plus $25 of monthly credits, and per-turn metering beyond that (tonic.ai/pricing, as of July 2026). Whether a credit-pool plan or a metered one fits depends on how often you seed; the [test data tool pricing](/compare/test-data-tool-pricing) comparison lays both models side by side, and for a feature-by-feature look at the two seeding workflows there's [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate). If you want the wider category — DIY scripts, web generators, masking tools, and schema-aware seeders next to each other — the [data seeding tools](/blog/data-seeding-tools) guide covers the field. ## The ten options at a glance No ranking is implied by the order here — each row answers to its own job, and a tool built for someone else's problem is not a worse tool. Details are current as of July 2026. | Tool | Job it fits | Delivery | Self-serve today? | Pricing | | --------------- | ------------------------------ | ------------------------ | ------------------------ | ------------------------------------ | | **Delphix** | De-identification | Virtualization + masking | No — sales | Quote-based | | **K2view** | De-identification (entity TDM) | Enterprise platform | No — sales | Quote-based | | **Datprof** | De-identification (mid-market) | Masking + subsetting | No — sales | Quote-based | | **Greenmask** | De-identification (Postgres) | OSS CLI, `pg_dump`-style | Yes (self-host) | Open-source | | **Neosync** | De-identification (archived) | OSS, unmaintained | No — archived 2025 | n/a | | **SDV** | ML-training synthesis | Python library | Yes (self-host) | MIT, free | | **YData** | ML-training synthesis | Library + platform | Open lib + managed | Open core, paid Fabric | | **MOSTLY AI** | ML-training synthesis | Platform + Python SDK | Yes (SDK is open source) | Open-source SDK; platform commercial | | **NVIDIA NeMo** | ML-training synthesis | NeMo microservices | No — sales | AI Enterprise, sales-gated | | **Seedfast** | App-database seeding | CLI + MCP server | Yes | $16/$69 mo, credit pool, free plan | Find your job in the middle column, and the one-or-two rows that share it are your real shortlist. The rest are answering a different question well. ## Frequently asked questions ### Is there a free Tonic.ai alternative? That depends on the job. The open-source tools are free as in no license fee — Greenmask for Postgres masking, SDV for ML synthesis — with the operational work moved onto you. For app-database seeding, Seedfast has a permanent free plan that takes no card and refills to $5 of credits each month. And if you're staying in the Tonic ecosystem, Fabricate's own free plan (credits that vary by signup email, tonic.ai/pricing, as of July 2026) is a fourth path worth knowing about. ### Is there an open-source alternative to Tonic? Yes, though again it splits by job. Greenmask is the actively maintained masking option for Postgres. ML synthesis splits between MIT-licensed SDV and YData, which ships an open core beneath its commercial platform. We found no open-source tool as of July 2026 that matches Structural's full enterprise provisioning at scale — the OSS projects cover the core masking or generation, not the environment orchestration a large team leans on. ### What's the best alternative to Tonic Structural specifically? For enterprise de-identification and provisioning, Delphix and K2view are the closest like-for-like. Datprof fits when the enterprise suites are heavier than the job needs, and Greenmask fits when you want open-source and you're on Postgres. In our reading the deciding factor is scale — how many environments you provision and how much data you move — rather than any single feature checkbox. ### I need data for a dev or test database, not for training a model. Which one? That's the seeding job, and Seedfast handles exactly that one. Pointed at a Postgres database, Seedfast turns a plain-English scenario into a dataset whose references all resolve to rows that exist, and the same command runs unattended as one CI step after your migrations. Tonic Fabricate covers this job too and is the stronger choice for in-browser exploration or for the non-Postgres engines it supports. If you're weighing the two, the head-to-head linked earlier walks the workflows feature by feature. ### Which Tonic product am I actually replacing? Structural handles de-identifying production data — that's Job 1. Job 2 belongs to Fabricate, Textual, and Tonic Datasets, covering synthetic and unstructured data for models. If you were reaching for Fabricate to fill a development database, that's the seeding job (Job 3), and it's the single place Seedfast belongs. Naming the product first spares you a shortlist of tools designed for a different problem. ## Start from the product, not the tool list The reason "Tonic.ai alternatives" returns noise is that it folds three different shopping trips into one search box. Name the Tonic product you're leaving and the shortlist shrinks to something you can evaluate in an afternoon instead of a grid of twenty tools solving unrelated problems. Most of what lands on this page isn't Seedfast's job, and the sections above hand you off without a pitch. Where a development, test, or staging database needs connected, schema-valid rows, Seedfast is the one thing here built for the job. Seedfast needs a connection string and one sentence of scenario, and the rows are in your tables a few minutes later, with the exact commands in the [getting-started walkthrough](/docs/seed-your-database). Monthly plans, each with its own pool of credits included, sit on the [pricing page](/pricing) next to the free plan, which never asks for card details. --- # Seedfast, a Gretel alternative for self-serve users left after the acquisition > Source: https://seedfa.st/compare/gretel-alternative > Last updated: 2026-09-10 _You went looking for a Gretel alternative, typed gretel.ai into the browser, and landed on nvidia.com. That redirect is the short version of what happened. NVIDIA acquired Gretel in March 2025 — reported at somewhere north of $320 million — and over the following year the self-serve product developers used to sign up for on their own got folded into NVIDIA's enterprise stack. Nobody sent the free-tier and self-serve accounts a migration plan. This is that plan, and it splits by what you were actually using Gretel for, because two different jobs hid under one login._ > **TL;DR** — Gretel's self-serve synthetic-data platform is effectively discontinued after the NVIDIA acquisition. As of July 2026, gretel.ai redirects to nvidia.com, the `gretelai` GitHub organization is archived, and the old pricing page returns a 404. The technology now ships inside NVIDIA NeMo (Data Designer and Safe Synthesizer microservices), licensed through NVIDIA AI Enterprise with no public pricing. Were you generating synthetic data for ML training or privacy? Your like-for-like replacements are YData, MOSTLY AI, the open-source SDV, or NeMo itself. Were you filling an application database for development, testing, or demos? That is a different job, and the one [Seedfast](https://seedfa.st/) is built for. ## Key Takeaways - NVIDIA acquired Gretel in March 2025 (reported above $320M). As of July 2026, gretel.ai 301-redirects to nvidia.com, the `gretelai` org's repositories are archived (read-only), and the legacy `/pricing` page returns a 404. - The old self-serve free tier — a modest monthly credit allowance — has been discontinued. No public sign-up flow remains for the standalone product. - Gretel's technology was absorbed into NVIDIA NeMo as the Data Designer and Safe Synthesizer microservices, available through NVIDIA AI Enterprise. Pricing is sales-gated, so there is no self-serve tier replacing the one you lost. - Which Gretel alternative is right depends entirely on the job you ran it for. Model-training and privacy-synthesis users should evaluate YData, MOSTLY AI, SDV, or NeMo. Seedfast takes the other job, filling an application database so the software reading it actually boots. - Seedfast covers that second job on PostgreSQL. Give it a connection string and a scope in plain English, and it reads the schema as it stands at that moment, then writes rows into every table the scope touches, each reference pointing at a row that exists. Pricing is a monthly plan that includes a pool of credits, and the free plan takes no card. ## What happened to Gretel Gretel launched in 2019 around one promise, privacy-preserving synthetic data. Train a model on a sensitive dataset, generate a synthetic version that keeps the statistical shape without carrying real records forward, and hand that safe copy to whoever needs it. Developers took to it because you could try it without a sales call — there was an API, a Python SDK, and a free tier that let you kick the tires before committing a budget. NVIDIA announced the acquisition in March 2025. Reporting at the time put the deal above $320 million, and NVIDIA was direct about the intent. Gretel's synthetic-data technology would power its own AI platform rather than continue as an independent self-serve product, and that is how the next year played out. Here is where things stand as of July 2026. The signals are easy to miss one at a time, so it helps to see them together: - **gretel.ai redirects to nvidia.com.** The standalone marketing site is gone; the domain now points at NVIDIA. - **The `gretelai` org's repositories are archived (read-only).** You can still clone the SDK and example repos, but nothing new gets merged into them. - **The old pricing page 404s.** With no self-serve plan page, there is no free tier and no way to start a fresh paid subscription on your own. - **The technology moved into NVIDIA NeMo.** Gretel's capabilities re-emerged as two NeMo microservices — Data Designer for synthetic dataset generation, Safe Synthesizer for privacy-preserving synthesis — distributed under NVIDIA AI Enterprise. ![Timeline from Gretel's 2019 launch to July 2026 — the March 2025 NVIDIA acquisition, the self-serve wind-down, and the move into sales-gated NeMo](/blog/gretel-alternative-1.svg) This is not the [Snaplet Seed alternative](/compare/snaplet-seed-alternative) situation, where a company wound down and open-sourced the code on its way out the door. Gretel was absorbed instead, so the technology arguably sits in a bigger, better-funded home now. What changed for a self-serve user is narrower and more annoying than a shutdown. That bigger home just does not sell to you the way Gretel did. ## What this means if you were using Gretel The honest framing is not "Gretel is dead" — the underlying technology is very much alive inside NVIDIA. The question that matters to you is whether the thing you depended on still exists in a form you can actually reach, and for self-serve accounts the answer is mostly no. A few risks are concrete enough to plan around. **New self-serve sign-ups are closed.** If your team was going to add seats or spin up a new project on the old free tier, that path no longer exists. Onboarding a new hire onto Gretel the way you did in 2024 is not an option in 2026. **The free tier is discontinued.** The credit-based free plan that made Gretel easy to adopt is not coming back. NVIDIA AI Enterprise licensing is the replacement, and it starts with a conversation rather than a credit card. **The SDK is frozen.** Because the `gretelai` GitHub org is archived, the open-source client you may still have pinned in `requirements.txt` will not get patches. It runs until a dependency bump or a Python release breaks it, and then it stays broken. **Hosted endpoints carry sunset risk.** If any of your pipelines still call a legacy Gretel Cloud endpoint, treat that as borrowed time. Post-acquisition APIs get deprecated on the acquirer's schedule, not yours, and rarely with generous notice. Anything running in CI against a hosted Gretel call is a good candidate to migrate before it fails on its own. **Support moved upmarket.** The help a self-serve user got — docs, a community, a responsive issue tracker — is now enterprise support attached to a license, which suits a large team standardizing on NVIDIA and does little for a solo developer who wanted an API key and a quickstart. None of these is an emergency on its own, but together they mean the product you evaluated is not the product you can buy today, so it is worth choosing where you go next deliberately rather than waiting for a broken build to force the decision. ## The fit split — which job did Gretel do for you? Gretel wore two hats, and the correct replacement is different for each one. Before you shortlist anything, work out which hat you were wearing, because picking a tool for the wrong job is how teams end up paying for capabilities they never touch. The distinction is the same one that separates [model-training data from application test data](/blog/best-ai-test-data-generator). Model-training data answers to statistical fidelity — the output has to reproduce the distributions and rare cases of a real dataset well enough to train on. Correctness is what application data answers to instead — every foreign key pointing at a row that exists, unique constraints holding, required columns filled, so the app boots instead of throwing on the first insert. A tool tuned for one is rarely much good at the other, and Gretel's heartland was firmly the first. If your data was for training, fine-tuning, or evaluating a model, or for handing a de-identified copy of a sensitive dataset to another team, keep reading the ML-training section below; if it was for filling a development, test, staging, or demo database your software runs against, skip to the application-database section — the job Gretel was always overkill for. ![Decision diagram sorting former Gretel users into two lanes — model-training and privacy synthesis toward NVIDIA NeMo, MOSTLY AI, YData, or SDV, and application-database seeding toward Seedfast](/blog/gretel-alternative-2.svg) ## Choosing a Gretel alternative for model-training data Say your Gretel usage was genuinely about models and privacy, whether that meant synthesizing a training set, standing in for data you could not share, or evaluating on realistic-but-fake records. These options land closest to what you had, and every claim here is dated to July 2026 because this market moves fast. **NVIDIA NeMo (Data Designer + Safe Synthesizer).** This is the literal continuation of Gretel's technology, so an organization already on NVIDIA AI Enterprise, or willing to get there, will find it the least-surprising destination — same lineage, more compute behind it. What you pay for is the shape of the relationship, now a licensed enterprise product with sales-gated pricing rather than an API key you provision yourself. **MOSTLY AI.** Squarely in the fidelity-first camp, MOSTLY AI sells a hosted platform for tabular synthesis and, as of July 2026, maintains an open-source Synthetic Data SDK alongside it, so you can start in code the way Gretel users did and move to the managed platform when the workload justifies it. Statistical fidelity and privacy mechanisms are the product's whole pitch, which makes it a close spiritual match for the synthesize-then-share workflow Gretel popularized. **YData.** Being Python-native, YData sits closest in spirit to Gretel's SDK-first workflow. The open `ydata-synthetic` library plus the commercial Fabric platform give you a self-hostable path and a managed one, so teams that liked living inside a notebook with Gretel tend to feel at home here. **SDV (Synthetic Data Vault).** SDV is the open-source route on this list, an MIT-licensed Python library for tabular, relational, and time-series synthesis. You self-host and pay nothing in license fees, though you own the operational burden yourself. If your Gretel bill was the thing that hurt and you have the engineering time, this is where we would start. For a broader survey of the AI-synthesis field and where each tool's strengths actually lie, the [best AI test data generator](/blog/best-ai-test-data-generator) guide lays out the model-fidelity camp against the schema-correctness camp in detail. Tonic competes in this lane too, with a multi-product suite that deserves its own map — the [Tonic.ai alternatives](/compare/tonic-ai-alternatives) rundown sorts it by job. The short version is that if you are training a model, one of the four above is your Gretel alternative and Seedfast is not, because it does not try to reproduce a distribution and has no business on that shortlist. ## Choosing a Gretel alternative for application databases The other group never used Gretel to train anything; plenty of teams reached for it to get realistic-looking data into a dev, test, or staging database — a synthetic-data tool pressed into service as a database seeder. That works on a toy schema and stops the moment the schema has forty tables and real foreign keys, because a fidelity-first synthesizer produces rows that look plausible on their own and then fail on insert when `order.customer_id` points at a customer nobody generated. The bar here was never distributional realism but whether the data holds together well enough for the app to run. [Seedfast](https://seedfa.st/) is built for exactly that. Connect it to your application's database and Seedfast reads the live schema, then generates a connected dataset that satisfies the constraints it finds, leaving you no relationships to hand-wire. No production access is required at any point, because the rows are generated rather than copied out of something real. Gretel's question was how closely a generated dataset tracks a real distribution. For a model about to be trained on the output, that is the right question to ask. Seedfast answers a narrower one, whether the schema in front of you comes out populated at all, which is what a dev or CI database needs settled before its tests run. ```bash # Point Seedfast at your database and describe what you want seedfast connect seedfast seed --scope "50 users, each with 3 projects and a handful of tasks, plus one admin account" ``` There is no model to fit and no export step. The scope is a sentence and the schema supplies the structure, so the rows come out connected. When a migration changes the schema, the next `seedfast seed` reads the new shape — there is no generation script pinned to yesterday's tables. If you are new to the category itself, the [database seeding](/blog/database-seeding) guide walks through why hand-maintained seed files break and what reading the schema fixes. A few things matter to the specific person who is here because Gretel left: - **Seedfast fits AI coding agents.** It ships an MCP server, so if you work in Claude Code, Cursor, or another MCP-aware assistant, the agent can seed a database inside the conversation instead of you shelling out. Gretel never offered that, and given its timeline it never would have. - **Seedfast runs in CI.** A single `seedfast seed` step after your migrations leaves every pipeline run with a freshly seeded database, and an API key covers the non-interactive auth. There is no hosted-endpoint sunset to worry about here, because Seedfast writes rows into your own database rather than standing up a synthesis service you then have to keep alive. - **The pricing stays self-serve and predictable, which is the part you just lost.** Seedfast charges a monthly plan that includes a pool of credits, $16 a month for $32 of credits or $69 for $180, and a run draws from that pool by how much data it generates. Under both sits a free plan that takes no card and refills $5 of credits a month, about eight runs on a 30-table schema. After Gretel's move to sales-gated licensing, signing up and seeding a database the same afternoon is most of the point. The [pricing page](/pricing) has the current plan breakdown, and the [test data tool pricing](/compare/test-data-tool-pricing) comparison sets a credit-pool plan side by side against metered and quote-gated tools. Seedfast is PostgreSQL-first today and reads whatever ORM defined your schema — Prisma, Drizzle, TypeORM, Django, Rails, or raw SQL all work the same way, because it talks to the database rather than the ORM. For a category-level look at where schema-aware seeding sits next to masking, DIY scripts, and web generators, the [data seeding tools](/blog/data-seeding-tools) comparison covers the field. ## Gretel vs the alternatives: a quick map The table below sorts the options by the job they actually do. Read it as a routing aid, not a scoreboard, because a tool being wrong for your job is not a tool being bad. Details are current as of July 2026. | Tool | Primary job | Delivery | Self-serve today? | Pricing | | ------------------- | ------------------------------- | ---------------------------- | ------------------------ | ------------------------------------ | | **Gretel (legacy)** | ML-training + privacy synthesis | API / Python SDK | No — discontinued | Free tier removed | | **NVIDIA NeMo** | ML-training + privacy synthesis | NeMo microservices | No | NVIDIA AI Enterprise, sales-gated | | **MOSTLY AI** | Model-training synthetic data | Hosted platform + Python SDK | Yes (SDK is open source) | Open-source SDK; platform commercial | | **YData** | Model-training synthetic data | Python library + platform | Open lib + managed | Open-source core, paid Fabric | | **SDV** | Tabular/relational synthesis | Python library | Yes (self-host) | MIT, free | | **Seedfast** | Application database seeding | CLI + MCP server | Yes | $16/$69 mo, credit pool, free plan | If your row is one of the top four, you are in the model-training lane and one of them is your like-for-like Gretel replacement. If the job was filling an application database, Seedfast is the bottom row for a reason, since it is the only one on the list whose whole design goal is a schema that boots. ## Frequently asked questions ### Is Gretel shutting down? Not in the usual sense of a company closing its doors. Gretel was acquired by NVIDIA in March 2025, and as of July 2026 the standalone self-serve product is discontinued — gretel.ai redirects to nvidia.com and the old pricing page 404s. The technology itself continues inside NVIDIA NeMo. So the code lives on; the product you could sign up for on your own does not. ### Is there still a Gretel free tier? No. The legacy free tier, a limited allotment of credits each month, was discontinued, and as of July 2026 no public sign-up flow is left for the standalone product; the old pricing page returns a 404, so there is no vendor page left to check it against. Access now runs through NVIDIA AI Enterprise, which is sales-gated and has no self-serve free plan. If a no-card starting point is what you are after, Seedfast has one, a free plan that refills $5 of credits a month and runs against your own Postgres schema. The open-source SDV library is the other route, with the operational work moved onto you. ### What replaced Gretel? Inside NVIDIA, Gretel's technology became two NeMo microservices, Data Designer for synthetic dataset generation and Safe Synthesizer for privacy-preserving synthesis, both under NVIDIA AI Enterprise. As a standalone product it was not replaced by NVIDIA with anything self-serve, which is why the market answer depends on your use case. YData, MOSTLY AI, and SDV cover model-training data. Seedfast covers the application-database side, reading a live Postgres schema over a connection string and writing connected rows the schema accepts on insert. ### Is the Gretel SDK or API still usable? The open-source SDK still installs, but the `gretelai` org's repositories are archived and read-only, so it is frozen and receiving no patches — plan on it breaking whenever a dependency or Python version moves under it. Any hosted Gretel Cloud endpoints your pipelines still call should be treated as sunset risk; acquirers deprecate legacy APIs on their own timeline. Migrating those calls before they fail is cheaper than debugging them after. ### What is the best Gretel alternative for test data? Seedfast is what we would point you at, assuming the database in question is a development, test, staging, or demo Postgres instance your application runs against. Seedfast reads that live schema over a connection string, turns a plain-English scope into connected rows whose foreign keys already resolve, and runs unattended as one step in CI on a monthly plan that includes a pool of credits. Reaching for another synthetic-data platform here aims at a different target. If your "test data" is really model-training data, SDV, YData, or MOSTLY AI fit that job better. ## Where to go from here The awkward part of an acquisition like this is that nothing technically broke. Your last Gretel run still sits in a folder somewhere and the SDK still imports; what stopped is your ability to keep buying it on your own terms, and you notice that the day you try to add a project or a teammate. Sorting into the right lane now keeps the migration small. Teams training or evaluating models can land on NeMo, YData, MOSTLY AI, or SDV, and the sections above map which of those fits which workflow. For the other lane, [Seedfast](https://seedfa.st/) reads whatever schema your application database currently has and writes the dev or CI dataset that gets the app running, which the [five-minute getting-started flow](/docs/seed-your-database) walks through end to end. On the free plan, $5 of credits arrive each month and no card is involved, which is enough to run the decision on your own data before anything is charged. --- # Test Data Generator Pricing (2026): Flat vs Metered vs Quote-Gated > Source: https://seedfa.st/compare/test-data-tool-pricing > Last updated: 2026-09-10 Test data generator pricing looks simple until you line the plans up, because the leading tools don't charge on the same axis. Tonic Fabricate meters per generation turn, Mockaroo charges by the rows in a file, GenRocket sells a quoted annual license, and Seedfast sells a monthly plan that includes a pool of credits, drawn down by how much data each run generates. Match those plans against each other and identical monthly figures buy incomparable things, since a bill that climbs with every turn and a pool that drains with every row behave nothing alike at the same sticker price. The price question still deserves a straight answer, and that answer only makes sense read next to what each dollar actually buys. ## Four test data generator pricing models at a glance The table lines up all four on the axis that actually drives each bill, rather than on a single headline number; the [full comparison hub](/compare) has the feature-by-feature matrix behind the prices. | Tool | Model | Entry paid price | Free option | Bill scales with | Source | | --------------- | ----------------------- | ----------------- | --------------------- | ------------------ | ----------------------------------------------------------- | | Mockaroo | Rows per file, annual | $60/yr (Silver) | 1,000 rows/file | Dataset size | mockaroo.com/pricing | | Tonic Fabricate | Credits + per-turn | $29/mo (Plus) | $5–10/mo credits | Generation volume | tonic.ai | | GenRocket | Quote-gated license | ~$55K/yr (anchor) | None published | Projects and scope | genrocket.com/value-for-money | | Seedfast | Monthly plan, credit pool | $16/mo (Basic) | Free plan, no card | Credits per run | [seedfa.st](/pricing) | Mockaroo and GenRocket figures were verified June 2026 against the linked sources, Tonic Fabricate's pricing was rechecked in July 2026, and the Seedfast figures come from its own pricing page. ## Mockaroo prices by the size of the file Mockaroo pricing is the easiest of the four to reason about, because it turns on a single variable, the number of rows in one generated file. The free tier caps a file at 1,000 rows and allows 200 API requests a day, generous enough for one-off flat exports and fine for a demo or a fixture. Paid tiers raise the row ceiling, verified June 2026 against mockaroo.com/pricing. Silver at $60 a year lifts a file to 100,000 rows, Gold at $500 a year to 10 million rows, and Enterprise at $7,500 a year removes the cap. What that buys is volume rather than structure, so the bill tracks how large a dataset gets, not how tangled the schema is. One wide table of rows is cheap here. A Postgres schema of twenty related tables costs no more, because Mockaroo never models the relationships between them in the first place. Where row count is the wall you keep hitting, rows-per-file is a fair axis to pay on, and the [Mockaroo alternative](/compare/mockaroo-alternative) page picks up where that axis stops fitting. ## Tonic Fabricate meters what you generate Tonic Fabricate pricing works the opposite way, turning on how much you generate rather than how much comes out. It runs on generation credits plus token usage, checked July 2026 against tonic.ai/pricing. The free plan includes $5 a month in credits for a personal email, or $10 with a work email and every model unlocked, enough for light exploratory work and a real feel for the browser experience. Plus is $29 a month and folds in $25 a month of credits, after which pay-as-you-go kicks in at roughly $0.17 for a standard turn and about $0.37 for a complex, multi-table one. The structure matters more than the exact cents, because every run past your credits spends more of them, so the bill rises with usage. That suits occasional interactive sessions and gets harder to hold still once a generator lives inside CI and fires on every build, where per-turn cost compounds with build frequency instead of team size. The [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate) comparison runs the pricing math through in full. ## GenRocket quotes an annual license GenRocket sits in a different buying motion altogether. It publishes no public price list, no free tier, and no trial; the pricing page routes you to a quote request, so the number reaches you through sales rather than off a page. The one figure GenRocket publishes itself, on its value-for-money page (as of June 2026), anchors licensing at about $55,000 a year, framed there against legacy test data management it says can run past a million dollars. That tells you which tier you are shopping in. With a quote-gated tool the license is rarely the whole cost. The entry plan carries a twenty-project minimum, a fixed onboarding block, and a procurement lane you clear before the first row lands, so time-to-first-seed is measured in a sales cycle rather than minutes. For a team modeling scenario data across many databases, that overhead is the platform doing its job. For one Postgres schema that needs rows in CI, it is a lot of platform to buy, which the [GenRocket alternative](/compare/genrocket-alternative) page takes apart. ## Seedfast charges a monthly rate with a credit pool Seedfast charges a monthly rate that includes a pool of credits, with no table or seed limits underneath, verified against [Seedfast pricing](/pricing) in August 2026. Basic is $16 a month for $32 of credits and Premium $69 for $180, so every plan carries more credits than it costs. A credit is spent per seeding run rather than per row, and a full session on a 30-table schema runs about 60 cents, which puts Basic at roughly 50 runs a month and Premium at around 300. Evaluation runs free first, and stays free: the Free plan takes no card, never expires, and refills to $5 of credits each month, enough to seed a real schema in CI and watch it hold rather than poke at a toy. The subscription price is fixed and known before the month starts, while the credit pool underneath it is what actually moves. A build schedule heavier than the plan expects draws it down faster, and topping up mid-cycle covers the gap. The honest limit is that Seedfast is Postgres-only, so a multi-engine shop is better served elsewhere. Inside its lane, seeding a Postgres database in CI, the subscription is a fixed line item, with the credit pool the one number worth watching in a busy month. ## How to run a real cost comparison Comparing sticker prices tells you little here, because the axes differ. A more honest estimate takes how you will actually use the tool and pushes it through each model. Count the seeds or generation runs you expect in a typical month, multiply by the cost each model assigns to a run, and the shapes separate quickly. A rows-per-file tool charges the same whether you generate once or fifty times, so its cost stays flat but capped by volume. A per-turn tool multiplies its per-run price by your run count, so a generator that fires on every CI build carries a bill that grows with build frequency. A quote-gated license is fixed but large, and a monthly plan with a credit pool is fixed and small at the subscription level, though the pool itself still draws down by how much a busy month generates. Forecastability is the quiet variable most pricing pages skip. A cost you can predict a year out is easier to defend in a budget than a lower one that swings with usage. Vendors also reprice over time, so confirm the current figure at each source before you commit. ## Frequently asked questions ### How much does Mockaroo cost? Mockaroo is free for files up to 1,000 rows with 200 API requests a day, then paid by file size on annual plans, verified June 2026 at mockaroo.com/pricing. Silver is $60 a year for 100,000 rows per file, Gold $500 a year for 10 million, and Enterprise $7,500 a year for unlimited rows and users. The bill follows dataset size, so a larger file costs more while schema complexity costs nothing. ### How much does Tonic Fabricate cost? Tonic Fabricate runs on generation credits plus per-turn usage, checked July 2026 at tonic.ai/pricing. The free plan includes $5 a month in credits for a personal email or $10 for a work email with full model access, Plus is $29 a month with $25 a month in credits, and beyond credits you pay roughly $0.17 per standard turn and $0.37 per complex turn. Because it meters generation, the bill scales with how much data you produce, which makes a CI generator that runs on every build the case to model most carefully. ### Which test data generator is cheapest for CI use? Cost in continuous integration tracks how often the pipeline fires, because a generator wired in after migrations runs on every build. Tonic Fabricate's per-turn meter therefore climbs with build frequency (as of July 2026, tonic.ai/pricing), and Mockaroo's rows-per-file ceiling turns into repeated export-and-import work at the same cadence (as of June 2026, mockaroo.com/pricing). Seedfast charges the same plan price whether the pipeline seeds once a week or on every commit, and a run draws from the credits the plan already includes, roughly 60 cents on a 30-table schema, so the invoice is known before the month starts. Which of the three comes out cheapest still depends on your build volume, so estimate runs per month against each model rather than trusting a headline price. ### How much does Seedfast cost? Seedfast charges a monthly rate that includes a pool of credits, and a run draws from that pool by how much data it generates rather than by row or by token, verified at [Seedfast pricing](/pricing) in August 2026. The [free plan](/pricing) takes no card, never expires, and includes $5 of credits a month; Basic is $16 a month for $32 of credits and Premium $69 for $180. Plans differ by how many credits they include, not by rows, tokens, or table counts, and there are no table or seed limits on any of them. ## Match the pricing model to how you work The sharper question is not which number is smallest but which axis matches how you work. Flat, occasional test data is exactly what Mockaroo's row-based annual plans are priced for (as of June 2026, mockaroo.com/pricing). Generating interactively in a browser, where AI-assisted iteration is most of the value, suits Tonic Fabricate's credit model, with the caveat that CI usage compounds it (as of July 2026, tonic.ai/pricing). GenRocket's quoted license is built for governing test data across many systems at enterprise scale, and that is the scope it expects you to have (as of June 2026, genrocket.com/value-for-money). And where the job is seeding a Postgres database in CI on a cost you can defend in a budget a year out, Seedfast is the predictable one, since the subscription holds steady each month and only the credit pool underneath it tracks how much the pipeline actually runs. Evaluating it costs nothing, since the [free plan](/pricing) takes no card, refills $5 of credits each month, and does not expire. --- # Seedfast, a drizzle-seed alternative that is not tied to your ORM > Source: https://seedfa.st/compare/drizzle-seed-alternative > Last updated: 2026-09-10 If you already use Drizzle ORM, drizzle-seed is the obvious first answer for filling a database with test data, and for a lot of cases it's the right one. It's the Drizzle team's own seeding package, it's free and open source, and it generates deterministic data straight from your Drizzle schema. So a drizzle-seed alternative is worth looking at only when you hit one of its edges: it works only on a Drizzle schema, its text columns get lorem-ipsum rather than meaningful values, its generator catalog is fixed, and circular foreign keys are a documented sore spot. This is the head-to-head for the developer who started with drizzle-seed, likes generating from the schema, and is now asking a narrower question: can I get realistic, meaningful data across a database that isn't only a Drizzle project, with one command in CI and an integration my AI coding agent can call? That's where Seedfast and drizzle-seed diverge. ## Seedfast vs drizzle-seed at a glance Both read a schema and generate relational data from it, so neither is "fake data" in the throwaway sense. drizzle-seed reads the schema from your **Drizzle schema object** and is a free, deterministic library you call from a TypeScript script. Seedfast reads the schema from the **live Postgres database** on every run, generates **semantically realistic** data from a natural-language scope, resolves tables that reference each other in a loop without leaving an orphaned row, runs as one CLI command in CI, and exposes the same run as an MCP tool for AI coding agents. drizzle-seed is the better fit when you're inside a Drizzle project and want free, reproducible fixtures. Seedfast is the better fit when the data has to look real, the database isn't only a Drizzle app, or the seed has to run in a pipeline. ## What drizzle-seed does well drizzle-seed does four things genuinely well: it's first-party and free, it generates straight from your Drizzle schema, it's deterministic, and it ships a catalog of real-data generators. A comparison that pretends the competitor is bad isn't worth reading, so if you're on Drizzle, reach for it first. - **It's first-party and free.** drizzle-seed is maintained by the Drizzle team and published under Apache-2.0, so there's no cost and no third-party risk — it tracks Drizzle ORM directly (it requires a recent `drizzle-orm`). - **It generates straight from your schema.** Point it at your Drizzle schema and call `seed(db, schema)`; it picks default generators per column type and inserts rows with no configuration. By default it creates a small batch per table, and you raise that with a `count`. - **It's deterministic.** drizzle-seed uses a seedable pseudo-random generator, so a fixed `seed` number reproduces the same rows on every run. That's the property you want for unit-test fixtures and bug repros, and it's something a model-driven generator doesn't give you for free. - **It has a solid built-in catalog.** Generators like `firstName`, `lastName`, `email`, `companyName`, `city`, and `valuesFromArray` draw from real datasets, so names and places look plausible, and `refine` lets you override generators or set per-table counts. If your project is a Drizzle app and you want free, reproducible fixtures with believable names, drizzle-seed already does the job. The rest of this page is about the cases it isn't built for. ## Show me the seed run Here's the concrete difference. drizzle-seed runs inside your TypeScript, against a Drizzle schema you import: ```ts import { seed } from "drizzle-seed"; import { db } from "./db"; import * as schema from "./schema"; await seed(db, schema).refine((f) => ({ users: { count: 100 }, })); ``` Seedfast runs as a command against a Postgres connection string. It reads the real schema and inserts rows with all their foreign keys already resolved, in one step, with no schema object to import: ```bash seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" → Connected to PostgreSQL → Found 34 tables, 67 foreign keys → Generating data... → Done. Seeded 12,847 rows in 6.3s ``` The drizzle-seed call is part of your app's code and needs the Drizzle schema to exist. The [Seedfast](https://seedfa.st/) command needs only a connection string, so it works on any Postgres database — a Drizzle app, a Prisma app, a plain SQL schema, Supabase, Neon, or RDS — and it re-reads the [live schema](/blog/referential-integrity) every run, so a new migration is picked up with nothing to reconfigure. The same invocation is exposed as an MCP tool, so an AI coding agent can run the seed directly. ## drizzle-seed alternative comparison The rows are workflow steps, not capability stars. The first row is a tie — both generate from a schema. The rows below it are where the two diverge: where the schema comes from, how realistic the values are, how cycles are handled, and how it runs in a pipeline. | Workflow step | drizzle-seed | Seedfast | | ------------------------------------- | --------------------------------------------- | ------------------------------------------------- | | Generates from a schema | Yes — from the Drizzle schema object | Yes — from the live Postgres schema | | Works without an ORM / on any Postgres| No — needs a Drizzle schema | Yes — reads any Postgres over a connection string | | Data realism for text columns | Fixed catalog; text falls back to lorem-ipsum | AI-generated, semantic from a natural-language scope | | Custom / context-specific generators | Fixed set; no custom-generator API | Natural-language scope describes the data you want | | Circular foreign keys | Documented inference limit; reported to loop (#3635) | Handles circular references automatically | | Runs as a one-command CI seed step | No — a library call inside your TS app | Yes — one command takes a connection string | | MCP-native for AI coding agents | No | Yes — `seedfast_run` for Claude Code, Cursor | | Database coverage | **Postgres, MySQL, SQLite** | Postgres only (Supabase, Neon, RDS, plain PG) | | Cost / license | **Free, open source (Apache-2.0)** | Free plan, then paid plans with a credit pool | Two rows are bolded because they're the honest concessions: drizzle-seed also seeds MySQL and SQLite, which Seedfast doesn't, and it's free and open source where Seedfast is a paid product. The rest of the table is where Seedfast pulls ahead: it reads any live schema, generates semantic data, handles circular references, and seeds in one CI command, the things a Drizzle-only library doesn't do. ## It only works on a Drizzle schema The biggest structural difference isn't a feature, it's the coupling. drizzle-seed is an [ORM-coupled seeder](/blog/database-seeder): it takes a Drizzle `db` instance and a Drizzle schema, and it has nothing to read without them. That's fine when the whole project is Drizzle. It stops being fine the moment the database isn't — a service written against Prisma or `node-postgres`, a schema managed by raw SQL migrations, or a database you don't own the ORM layer for. Seedfast is database-coupled instead. It connects to Postgres and reads the live schema directly, so the ORM (or absence of one) doesn't matter. That also means it sees the schema as the database actually is after the last migration, not as a TypeScript object that may have drifted from it. If you want the broader picture of how seeders differ on this axis, the [database seeder](/blog/database-seeder) comparison ranks the approaches, and the [best Postgres test data generator](/blog/best-postgres-test-data-generator) guide covers the field beyond these two. ## Lorem-ipsum versus data that means something drizzle-seed's people-and-places generators are good, but its catalog is fixed: `refine` only swaps in other built-in generators, so there's no API to register a custom one of your own. Columns it doesn't have a semantic generator for — descriptions, notes, product copy, domain-specific identifiers — fall back to lorem-ipsum text, random strings, or numeric filler. The data is structurally valid and reproducible, but a `product_description` reads as Latin filler and a free-text `account_status` column holds whatever the column type allows rather than a real status value. Seedfast generates from a natural-language [scope](/docs/scoping) instead of a fixed catalog. You describe the data — "fintech app with accounts, transactions, and varied balances" — and it produces values that fit the columns' meaning, not just their types. That's the difference between data that compiles and data that looks like your product, which matters when you're demoing, screenshotting, or testing logic that branches on real-looking values. The [generate test data with AI](/blog/generate-test-data-with-ai) guide goes deeper on that workflow. ## Circular foreign keys drizzle-seed's own documentation states it can't reliably infer references between tables when circular dependencies exist, and a reported issue (drizzle-orm issue #3635) documents the seeder looping on cyclical relations (as of June 2026). The workaround is manual: pick the related table by hand and break the cycle yourself. Circular foreign keys, a `users.default_org_id` that points at `orgs` while `orgs.owner_id` points back at `users`, are exactly the shape that makes insertion order hard, because neither table can be inserted first without the other's rows. Seedfast seeds that case automatically, a cycle that's strictly `NOT NULL` on both sides with no deferral included, so the [circular foreign key](/blog/circular-foreign-key-seed) case needs no hand-written SQL from you. [Run your first seed](/docs/seed-your-database) — free plan, about two minutes. ## Reproducible fixtures versus realistic seeds This one cuts the other way, and it's worth being straight about. drizzle-seed's determinism is a real advantage for one job: unit-test fixtures where you want the same rows every run so assertions are stable. A fixed `seed` number gives you that for free, and Seedfast doesn't try to be a bit-for-bit reproducible fixture generator. Seedfast is built for the adjacent job: seeding a database with realistic, referentially-correct data for development, demos, staging, and integration tests, where "looks real and respects the schema" matters more than "identical bytes every run". If your need is deterministic fixtures inside a Drizzle test suite, drizzle-seed is the better tool and you may not need an alternative at all. If your need is realistic volume across a whole database, that's the Seedfast lane. The [e2e test fixtures](/blog/e2e-test-fixtures) guide covers where each approach fits. ## Which one should you pick? **You're on Drizzle and want free, deterministic fixtures with believable names.** That's drizzle-seed, and you probably don't need anything else. It's first-party, it's reproducible, and it generates straight from the schema you already maintain. **Your database isn't only a Drizzle app, the data needs to look real, your schema has circular foreign keys, or the seed has to run in a pipeline.** That's where Seedfast fits. Seedfast reads any live Postgres schema over a connection string and writes values from a natural-language scope that match what the columns actually mean, keeping a child row pointed at a real parent even where the reference loops back on itself. The same command runs in CI and doubles as an MCP tool for AI coding agents, on a monthly plan with a pool of credits included rather than a per-row meter. Where drizzle-seed spreads across three databases, Seedfast goes deep on Postgres and charges money for it, which is the trade you make for the realism and the workflow. For the wider field of Postgres generators beyond these two, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison ranks tools on schema-awareness and CI fit, and the [data seeding tools](/blog/data-seeding-tools) guide covers the regulated and compliance angle. If you're comparing the other alternatives, see the [Snaplet Seed alternative](/compare/snaplet-seed-alternative) (the discontinued schema-aware generator), the [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate) (the AI generation agent), and the [Mockaroo alternative](/compare/mockaroo-alternative) (the column-level web generator). ## Frequently asked questions ### Is there a drizzle-seed alternative that works without Drizzle? Yes. Seedfast reads the schema from the live Postgres database over a connection string, so it doesn't need a Drizzle schema object and works the same against a Drizzle app, a Prisma app, a plain SQL schema, Supabase, Neon, or RDS. drizzle-seed is ORM-coupled: it operates on a Drizzle `db` instance and a Drizzle schema, so it only runs inside a Drizzle project. If your database isn't a Drizzle app, Seedfast is the more direct fit, because a connection string is the only thing it needs to find the schema. ### Does drizzle-seed generate realistic data? Partly. drizzle-seed has good built-in generators for names, emails, cities, and companies, so those columns look plausible. But its catalog is fixed and there's no custom-generator API, so columns without a matching generator fall back to lorem-ipsum text, random strings, or numeric filler. Seedfast generates from a natural-language scope, so text and domain-specific columns get values that fit their meaning rather than placeholder filler. ### How does drizzle-seed handle circular foreign keys? Not well, by its own documentation. drizzle-seed notes it can't reliably infer references when circular dependencies exist, and a reported issue (drizzle-orm issue #3635) documents it looping on cyclical relations, so the workaround is to break the cycle manually. Seedfast seeds that case automatically instead, a cycle that's strictly `NOT NULL` on both sides with no deferral included, so circular foreign keys need no hand-written SQL either way. ### Is drizzle-seed free, and is Seedfast? drizzle-seed is free and open source under Apache-2.0. Seedfast is paid, with a free plan under it that takes no card and refills $5 of credits a month, and the paid plans are monthly with a pool of credits included and no per-row or per-token meter. What Seedfast adds for that money is semantic data written to fit each column's meaning, a live-schema read on any Postgres database, circular references handled without hand-written SQL, and a one-command CI step exposed as an MCP tool, none of which a Drizzle-only library is built to do. ### Can drizzle-seed run as a CI step like Seedfast? drizzle-seed runs as a library call inside your TypeScript app, so seeding in CI means running a script that imports your Drizzle schema and database client. Seedfast runs as one command that takes a connection string, so it drops into a pipeline as a single unattended step after a migration, and the same run is exposed as an MCP tool. The [CI/CD database seeding](/docs/cicd-database-seeding) guide covers the setup, and [why you'd generate fresh data on every pipeline run](/blog/synthetic-data-ci-cd) covers the reasoning behind it. ## Seed any Postgres database in one command drizzle-seed stays the right answer inside a Drizzle project that wants free, reproducible fixtures. Once the database stops being only a Drizzle app, or the text columns have to read like your product rather than like Latin filler, [Seedfast](https://seedfa.st/) is what covers it, from one command and a connection string. There is no schema object to import and no ORM to have installed. [Run your first seed](/docs/seed-your-database) in about two minutes, or see [pricing](/pricing). Related guides: - [Best Postgres Test Data Generator](/blog/best-postgres-test-data-generator): the broader buyer comparison - [Database Seeder Comparison](/blog/database-seeder): how ORM seeders and standalone tools differ - [Circular Foreign Key Seed](/blog/circular-foreign-key-seed): the cycle case in depth - [Snaplet Seed Alternative](/compare/snaplet-seed-alternative) and [Tonic Fabricate Alternative](/compare/seedfast-vs-tonic-fabricate): the other schema-aware alternatives --- # Seedfast, a GenRocket alternative for teams that just need Postgres seeded > Source: https://seedfa.st/compare/genrocket-alternative > Last updated: 2026-09-10 You got a GenRocket quote, did the math, and stopped. GenRocket licensing starts at $55,000 a year (as of June 2026) with a 20-project minimum — sized for a bank standing up scenario-modeled test data across a dozen databases, not for filling one Postgres test database so your CI suite has rows to run against. For that smaller job, the right GenRocket alternative is Seedfast: a CLI that connects to your live Postgres database, reads the schema on every run, and writes rows that already satisfy the foreign keys — a free plan to start on, no onboarding block, no sales call. The rest of this page compares the two for a team whose real job is seeding a Postgres test DB. ## What's a good GenRocket alternative? The best GenRocket alternative for seeding one Postgres test database is Seedfast. Seedfast reads your live schema over a connection string, writes data that holds together across every table, and runs as a single step in CI, with no six-figure platform underneath it and no quote to wait on first. GenRocket stays the right choice when you need enterprise scenario modeling and central governance across many databases. ## Why people re-evaluate GenRocket GenRocket is a real enterprise platform, and for its intended buyer it does serious work: synthetic test data generation with scenario and use-case modeling, parallel and streaming generation, and coverage across many database types. Teams re-evaluate it when the platform is sized for a much bigger problem than the one in front of them. A few frictions show up when a Postgres-seeding team lands there. GenRocket doesn't publish a price list; its pricing page routes you to a quote request. The one public anchor — licensing from **$55,000 a year** — sits on GenRocket's own value-for-money page, read in June 2026, framed there against "traditional/legacy TDM" that "can cost $1,000,000 or more." That tells you the tier you're shopping in, and it's honest about the enterprise context. The licensing is built for scale. GenRocket's pricing page documents the Self Service tier with a minimum of **20 Test Data Projects**, five versions per project, unlimited users, multi-tenant hosting, training, support, and a 20-hour onboarding block. That's a lot of platform to absorb when your scope is one Postgres schema. And "Self Service" is the name of that tier, not a free signup — the same pricing page (as of June 2026) shows no public free tier and no published trial. Even the entry tier means a quote, not a browser you can start in right now. If you read that and thought "I just need rows in a Postgres database," Seedfast is built for you. You were quoted for a category you don't need yet. ## GenRocket vs Seedfast GenRocket and Seedfast differ on scope: GenRocket models synthetic data across many database engines, while Seedfast is a Postgres-only CLI that reads a live schema and seeds it in one command. The table compares them on the steps of actually getting data into a database, and GenRocket wins two of the rows outright. | Workflow step | GenRocket | Seedfast | | ------------------------------------- | ---------------------------------------------------- | ----------------------------------------------- | | Get started | Quote request → sales → onboarding block | Free plan, self-serve, one CLI command | | Reads your live schema | Per-project config, not a live read | Yes, reads live Postgres schema every run | | Foreign-key / referential integrity | Yes, enterprise relational generation | Yes, rows satisfy every FK, circular references included | | Runs as a CLI / CI step | Platform UI + integrations, not one CLI step | Yes, CLI + MCP, one pipeline step | | Database coverage | **Many databases, broad enterprise breadth** | Postgres only (Supabase, Neon, RDS, plain PG) | | Scenario / use-case modeling at scale | **Yes, its core strength** | No, natural-language scope, not scenario engine | | Pricing model | Quote-gated, from $55K/yr (Jun 2026), 20-project min | Free plan, then credit-pool plans, no per-seat/per-project | | Best for | Enterprise TDM across many systems | Seeding a Postgres test DB, self-serve | **Where GenRocket genuinely wins:** breadth and scenario depth. If you need synthetic data modeled across many database engines, with use-case scenarios, parallel and streaming generation, and governance over dozens of projects, GenRocket is built for that and Seedfast is not. Seedfast is Postgres-only and generates from a natural-language scope; it is not a scenario-modeling engine. For a team seeding one Postgres test DB, that narrower scope is the whole point. **[Start free — one CLI command, no quote →](/docs/seed-your-database)** [Seedfast](https://seedfa.st/) connects to a live PostgreSQL database, reads the schema on every run, and generates relational data from a plain-English scope: ```bash seedfast seed --scope "fintech app with 100 accounts, transactions, and varied balances" ``` Every row it writes already points at a real parent, with no relationships to wire up by hand, so a migration that adds a table or a `NOT NULL` column is picked up automatically with no seed code to edit. For the broader tool-shopping view, the [best Postgres test data generator](/blog/best-postgres-test-data-generator) comparison ranks the field on schema-awareness and CI fit. ## The category mismatch to know before you shop a Delphix alternative Seedfast and Delphix solve opposite problems. Delphix virtualizes and masks copies of real production data; Seedfast generates new synthetic data from a schema and never connects to production. GenRocket buyers often cross-shop Delphix (now the Perforce Delphix DevOps Data Platform), so this category split is worth getting straight before treating either as a like-for-like swap. Data virtualization delivers space-efficient virtual copies of a production database into non-production environments; data masking replaces sensitive values in those copies with realistic, de-identified substitutes while preserving referential integrity. Both need access to real production data. Perforce acquired Delphix in early 2024. So if you specifically need masked or virtualized copies of real production data, Seedfast isn't that tool. That is a [data masking](/blog/data-seeding-tools) job, and Delphix or a dedicated masking tool is what to evaluate on its own terms. Seedfast fits when a team reaches for Delphix-class tooling but really needs generated Postgres test data, not de-identified production. In that case you'd be buying a virtualization-and-masking platform, production-data pipeline and compliance surface included, to solve a problem Seedfast handles from the schema alone, with no production connection anywhere in the path. Delphix pricing is custom and quote-based, with no public list price. Third-party review sites cite figures in the hundreds of thousands annually, but those are reported user numbers — region- and scope-dependent, not a list price — so don't anchor a budget on them. ## Where the test data management cost gap actually is Enterprise test data management is a six-figure, sales-led category: GenRocket, Delphix, K2View, and Informatica are built around either production data or large-scale scenario modeling. That cost is justified when you're governing test data across many systems for a regulated enterprise, and hard to justify when the line item is "seed our Postgres test DB so CI has data." For the Postgres-seeding job, Seedfast starts at $0 and seeds a real schema on that plan, then moves to a monthly plan that includes a pool of credits, and nothing along the way is metered per seat, per project, or per TB. What you end up with is not a smaller enterprise TDM platform but a tool scoped to the narrower job. **[Start your first seed free →](/docs/seed-your-database)** For the full picture of where each category sits, the [test data management](/blog/test-data-management) overview covers the pillars and the [enterprise test data patterns](/blog/enterprise-database-test-data) guide covers what enterprise TDM looks like in practice. ## How Seedfast seeds a Postgres database Seedfast seeds a Postgres database in one command: point it at a live connection string, describe the scope in plain English, and it fills the schema with referentially intact rows. ```bash seedfast seed --scope "marketplace with 500 sellers, 5k listings, orders and reviews" ``` Every foreign key it writes already resolves, [circular foreign keys](/blog/circular-foreign-key-seed) included, then it re-reads the schema on the next run so the next migration doesn't break it. It runs as one step in a pipeline — see the [CI/CD database seeding](/docs/cicd-database-seeding) guide — and the MCP tool (`seedfast_run`) lets AI coding agents like Claude Code and Cursor seed a database directly. There are stack-specific walkthroughs for [seeding a Supabase database](/blog/supabase-db-seed) and [seeding a Neon database](/blog/seed-neon-database). ## How does this compare to the other alternatives? If you're cross-shopping the schema-aware and self-serve tools rather than the enterprise tier, the rest of the alternatives cluster covers the head-to-heads: - [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate): the closest direct competitor, schema-aware generation through a web UI with per-token metering, versus a live-schema CLI with credit-pool pricing. - [Mockaroo alternative](/compare/mockaroo-alternative): when the choice is between a column-level web generator and a relational, CLI-driven one. - [Tonic Fabricate vs Mockaroo vs Seedfast](/compare/tonic-fabricate-vs-mockaroo): the three-way comparison if you're weighing a web agent, a column generator, and a CLI at once. - [Snaplet Seed alternative](/compare/snaplet-seed-alternative) and [Neosync alternative](/compare/neosync-alternative): the migration paths off the discontinued open-source schema-aware tools. - [Data seeding tools](/blog/data-seeding-tools): the category-level comparison with the regulated/compliance angle, including masking versus synthetic generation. ## Frequently asked questions ### Is there a free GenRocket alternative? Yes, for the Postgres-seeding job. GenRocket is quote-gated and publishes no free tier and no price list (as of June 2026, per its pricing page). Seedfast seeds a real Postgres schema on its free plan, which takes no card and refills $5 of credits a month, so a live-schema, foreign-key-aware dataset lands without a quote and without a project minimum. That is not a like-for-like replacement for GenRocket's enterprise scenario engine; it is the right-sized tool for the smaller job. ### Delphix vs Seedfast — which should I use? Different problems. Delphix virtualizes and masks real production data, so it requires production access; Seedfast generates new relational data from your schema and never connects to production. Choose Delphix if you need masked or virtualized copies of real production data. Choose Seedfast if you need generated, referentially valid Postgres test data without a production pipeline. ### What's a lightweight test data management alternative for Postgres? Seedfast. It reads your live Postgres schema over a connection string and writes rows that already respect the constraints it finds there, and the same command runs as one step in CI, without the production-data pipeline, the project minimums, or the sales cycle that enterprise TDM brings with it. Pricing is a monthly plan that includes a pool of credits, with no per-token and no per-project meter. Seedfast deliberately doesn't do multi-database scenario modeling or production masking; those stay in the enterprise tier. ### Can Seedfast replace GenRocket entirely? Only for the Postgres-seeding subset of what GenRocket does. GenRocket is an enterprise platform for scenario-modeled synthetic data across many databases with central governance; Seedfast is a Postgres-only, schema-aware generator. There's no GenRocket import path or config port, so moving is a re-think for the seeding part of the job, not a migration. If you need GenRocket's breadth and scenario depth, keep it. If seeding a Postgres test DB is what you were really paying for, Seedfast handles that directly. ## Seed your Postgres database without the enterprise quote Nobody needs a six-figure TDM platform to get rows into one Postgres test database. [Seedfast](https://seedfa.st/) reads that database's live schema and writes realistic data where every reference checks out, in a single command, and the whole thing begins on a free plan instead of a quote request. [Run your first seed](/docs/seed-your-database), watch the [one-command demo](/#demo) first if you'd rather see it before installing, or check the [credit-pool terms](/pricing) for when procurement asks. Related guides: - [Best Postgres Test Data Generator](/blog/best-postgres-test-data-generator): the broader buyer comparison of Postgres generators - [Data Seeding Tools for Regulated Teams](/blog/data-seeding-tools): masking versus synthetic generation, with the compliance angle - [Enterprise Database Test Data](/blog/enterprise-database-test-data): what enterprise TDM actually looks like in practice - [Tonic Fabricate Alternative](/compare/seedfast-vs-tonic-fabricate) and [Mockaroo Alternative](/compare/mockaroo-alternative): the other schema-aware and self-serve head-to-heads --- # Seedfast, a Mockaroo alternative for data with referential integrity > Source: https://seedfa.st/compare/mockaroo-alternative > Last updated: 2026-09-10 Mockaroo is two products under one name: a random-data generator (define columns in a web form, export CSV/JSON/SQL/Excel) and a REST-API mocking host that serves fake endpoints from `my.api.mockaroo.com`. This page is about a Mockaroo alternative for the data-generation half — specifically for when the data has to land in a real relational database with valid foreign keys. (Came for the mock-API half? The tools to compare are Beeceptor, Mockoon, Apidog, or WireMock, not Seedfast.) As a generator, Mockaroo can relate columns inside schemas you build by hand in its web form, which is what its Dataset column type does. What it can't do is connect to your database and honor the foreign keys already defined there. A single throwaway table is no trouble. But point it at a Postgres schema where `orders` references `customers`, and you're back to exporting each table and wiring the relationships together yourself. Seedfast does that wiring for you by reading the live database instead of a form you filled in. ## What's the best Mockaroo alternative? The best Mockaroo alternative for relational data is [Seedfast](https://seedfa.st/) — a schema-aware generator that connects to your live PostgreSQL database and inserts referentially valid rows from a CLI, instead of exporting flat columns you stitch together afterward. It fits the case a web form was never built for: data that has to satisfy a real schema's constraints on insert. (Need to mock REST endpoints instead? That's a different tool — Mockoon or Beeceptor — not Seedfast.) And if the data you're after is realistic *demo* data — rows meant to look coherent to a prospect or investor watching the screen rather than to drive a test assertion — the [demo data guide](/blog/demo-data-generator) covers that case specifically. ## What are Mockaroo's limitations for relational data? Mockaroo is genuinely good at what it was built for: fast, flat, standalone datasets, exported as a CSV in under a minute with no install. Where it starts to pinch a relational buyer is first on cost. Mockaroo's free tier stops at **1,000 rows per file** and **200 API requests per day**, enough for a demo but not a realistic dataset, and past that you're on a paid plan that runs from **$60 to $7,500 a year** (full breakdown in the FAQ), billed by rows per file. That's fair for what Mockaroo is, but rows-per-file is the wrong axis when your bottleneck isn't volume, it's relationships. **The deeper limit is structural.** Mockaroo doesn't connect to your database, so it can't read or honor your existing schema's foreign keys. Its Dataset column type relates tables you define inside its own web form, yet the live schema in your Postgres stays invisible to it. A schema with twenty tables needs [referential integrity](/blog/referential-integrity) across all of them, and more free rows won't buy that. ## Mockaroo vs Seedfast vs Faker: comparison Each row is a workflow step for someone whose data has to land in a real Postgres database. The "Faker / hand-rolled" column is the $0 DIY baseline. Mockaroo wins two rows outright (bolded) — for a quick flat export or a hosted mock API, nothing here beats it. | Workflow step | Mockaroo | Faker / hand-rolled | Seedfast | | --------------------------------------- | ---------------------------------------------- | ------------------------------ | --------------------------------------------------- | | No install, instant flat CSV/JSON | **Yes — web form, fastest for one table** | No — you write code | No — CLI against a live DB | | Hosts mock REST API endpoints | **Yes — mock-API product** | No | No — not a mock-API tool | | Connects to your live Postgres | No — export then import yourself | No — you read the schema | Yes — reads live schema every run | | Cross-table FK / referential integrity | Manual — own web schema only, not your live DB | Manual — you wire FKs by hand | Yes — every child row points at a real parent, circular references included | | Runs as a CLI / CI step | Export-then-import; no live DB | Library, runs anywhere | Yes — CLI + MCP, one pipeline step | | Adapts to a migration automatically | No — redefine columns | No — edit the code | Yes — re-reads schema on next run | | Pricing model | Free ≤1,000 rows/file; $60–$7,500/yr (as of Jun 2026) | Free | Free plan, then flat paid, no per-row metering | Mockaroo's two bold rows are real wins. The relational rows below them are decided by one capability it doesn't have: a live database connection. ## Is there a Mockaroo alternative with a CLI? Yes — Seedfast. Mockaroo is web-first: you generate in a browser and download a file. A relational database wants data that's already valid against the live schema, and that's a command-line job. Seedfast is a CLI — and an [MCP](/docs/mcp-setup-guide) tool — that connects to a live PostgreSQL database, reads the schema on every run, and generates connected data from a plain-English scope: ```bash seedfast seed --scope "marketplace with 500 sellers, 5k listings, orders and reviews" → Connected to PostgreSQL → Found 22 tables, 41 foreign keys → Generating data... → Done. ``` Every table comes out already satisfying the schema's foreign keys, [circular references](/blog/circular-foreign-key-seed) included, whether or not a back-edge is nullable. Because the schema is read fresh each run, a migration that adds a table is picked up with no columns to redefine. The same command drops into [CI/CD database seeding](/docs/cicd-database-seeding) as one step, and the MCP tool (`seedfast_run`) lets AI coding agents like Claude Code and Cursor seed a database directly. ## Is there a free Mockaroo alternative? Yes, with the trade-offs stated plainly. Faker and ORM seeders are free, but they generate column by column, so you write the foreign-key wiring yourself and update that code every time the schema changes. Seedfast starts at $0 with $5 of credits a month and no card, and on that plan it still reads your live schema and writes rows that are already valid the moment they land, which works out to roughly eight runs a month on a 30-table schema. What a free tier is worth for relational work shows up there rather than in a row ceiling, since what you actually needed was connected data reaching a real database without you stitching the foreign keys yourself. For the broader method comparison, see [test data generation](/blog/test-data-generation). ## Which one should you pick? **Pick Mockaroo** for a quick flat CSV or JSON for a single table, or for its hosted mock API — both are fast and cheap, and the mock-API feature is one Seedfast doesn't offer at all. **Pick Seedfast** when the data has to satisfy foreign keys in a real Postgres database. Seedfast reads the live schema over your connection string and writes rows that hold together across every table, so the next migration lands without a code edit anywhere. That is the [best Postgres test data generator](/blog/best-postgres-test-data-generator) lane, and you can [start on the free plan](/subscribe/free) against your own schema. **Still want a web UI** over a CLI? Then your comparison is the [Tonic Fabricate alternative](/compare/seedfast-vs-tonic-fabricate) head-to-head, or [Tonic Fabricate vs Mockaroo](/compare/tonic-fabricate-vs-mockaroo) for all three side by side. Cross-shopping wider: [GenRocket](/compare/genrocket-alternative) (enterprise TDM), [Snaplet Seed](/compare/snaplet-seed-alternative), [Neosync](/compare/neosync-alternative), or the category-level [data seeding tools](/blog/data-seeding-tools) guide. ## Frequently asked questions ### How do Mockaroo's plan tiers differ? Mockaroo's free tier allows 1,000 rows per file and 200 API requests per day. Paid plans (verified June 2026 against mockaroo.com/pricing) are Silver at $60/year for up to 100,000 rows per file, Gold at $500/year for up to 10 million, and Enterprise at $7,500/year for unlimited data and users. Every tier prices on rows per file, so the bill tracks dataset size, not schema complexity — confirm the current numbers before budgeting. The [test data tool pricing comparison](/compare/test-data-tool-pricing) lines these tiers up against Tonic Fabricate, GenRocket, and Seedfast. ### Does Mockaroo handle foreign keys? Within its own web-defined schemas, yes: the Dataset column type lets one generated table reference another you've built in the form. Against your actual database, no. Mockaroo doesn't connect to it or read its schema, so it can't guarantee that a generated order points at a real customer in your Postgres. Seedfast closes that gap by connecting to the Postgres database itself, reading the foreign keys as they stand after your last migration, and writing every child row against a parent that is already there. ### Can Mockaroo seed a database directly? No. Mockaroo produces a file (CSV, JSON, SQL, or Excel) that you import yourself, in the right order, resolving foreign keys as you go. There's no command that connects to a live Postgres instance and writes rows into it. Seedfast closes that loop from the other end, since one command reads the schema over your connection string and inserts the rows directly, leaving no file to import and no order to work out. ### Is Mockaroo or Seedfast better for a Postgres database? It depends on the shape of the data. For a single flat table you'll export as CSV, Mockaroo is faster and simpler. For a Postgres schema with foreign keys, Seedfast fits better: it connects to the live database, reads the schema, and writes referentially valid rows, so ordinary inserts don't fail on a missing parent and nothing is left orphaned. Mockaroo also hosts mock APIs, which Seedfast doesn't — genuinely different jobs. ## Seed a relational schema, not a flat file The relational work is what a column generator leaves on your desk. [Seedfast](https://seedfa.st/) picks it up from the connection string you already have. Describe what the data should represent, and the rows land satisfying the foreign keys your schema defines, with no export step and nothing to reconcile afterward. The free plan takes no card and refills $5 of credits every month. [Run your first seed](/docs/seed-your-database) in about two minutes, or [watch what one command does](/#demo) before you install anything. Related guides: - [Best Postgres Test Data Generator](/blog/best-postgres-test-data-generator) — the broader buyer comparison - [Data Seeding Tools for Regulated Teams](/blog/data-seeding-tools) — the category-level comparison - [Tonic Fabricate Alternative](/compare/seedfast-vs-tonic-fabricate) — the schema-aware web-UI head-to-head - [GenRocket Alternative](/compare/genrocket-alternative), [Snaplet Seed Alternative](/compare/snaplet-seed-alternative), and [Neosync Alternative](/compare/neosync-alternative) — the rest of the alternatives cluster --- # Seedfast, a Tonic Fabricate alternative that writes into your own Postgres > Source: https://seedfa.st/compare/seedfast-vs-tonic-fabricate > Last updated: 2026-09-10 Tonic Fabricate is a hosted synthetic data product from Tonic.ai. You describe the data you need to a chat agent Tonic calls the Data Agent, it builds that dataset inside a database Fabricate runs for you, and usage past your monthly credits is metered in tokens. Anyone shopping for a Tonic Fabricate alternative usually wants something narrower than that whole workspace, namely their own PostgreSQL database holding coherent rows, put there by a command that also runs in CI. > **Where we land, September 2026, stated as our opinion rather than as a benchmark.** Fabricate is the broader product by a wide margin, listing eight database engines and a dozen export targets on its product page, and its chat agent will happily reason about a schema you have not built yet. What tends to surprise people partway through an evaluation is where the rows go, because Tonic's own documentation puts database connections behind an Enterprise license, so on the plans you can buy with a card the data lands in Fabricate's workspace and comes out of it as an export. [Seedfast](https://seedfa.st/) has no workspace to export from. It runs on your machine and fills the PostgreSQL database whose connection string you handed it. ## What Tonic Fabricate is, in Tonic's own words Their documentation opens by stating that "Tonic Fabricate allows you to create data from scratch," and everything after that follows the shape of the sentence. Inside a project you brief the Data Agent through a chat interface, adding clarifying details and attaching files as you go, and the example they publish reads "Create 70 customers. Each customer has 1-3 orders. Each order has 1-5 products. You can choose the distribution of the customers throughout the United States." How much the agent checks with you first depends on the mode, with Agent as the default, Plan drafting each action for review, and Autonomous doing the work "without asking you for any confirmation at all." Two details shape an evaluation more than the chat surface does. One is the plan table, where local databases are capped at 256 MB on Free, 1 GB on Plus and 5 GB on Enterprise, a single workspace comes with Free and Plus, and the row that decides the most says database connections are Enterprise only, a line their connection page repeats without hedging, "Database connections require an Enterprise license." Fabricate also happens to be mid-transition. Their docs home carries a deprecation notice for the older declarative product ("Fabricate has deprecated rule-based databases, which were only available with Enterprise plans"), and the API page notes that "Except for generating data, most API calls are designed for rule-based databases," which reads to us like a programmatic surface being rebuilt around the agent rather than around configuration. Give Fabricate its due on breadth. Their product page advertises generation for PostgreSQL, MySQL, Oracle, Databricks, Snowflake, MongoDB, SQL Server and SQLite, exports covering CSV, JSON, Parquet, Avro, Iceberg, DOCX, EML, SQLite, MongoDB, Snowflake and Amazon S3, mock APIs standing over the generated data, and a validation agent on Plus and above that re-checks what the Data Agent produced. Nothing in Seedfast's roadmap competes with that spread, and a team whose tables sit in Snowflake or SQL Server has already finished this comparison. ![Where the rows end up for a Tonic Fabricate alternative: a chat prompt fills Fabricate's own hosted workspace, capped 256 MB to 5 GB by plan and reached afterward through an export or an Enterprise connection, while a Seedfast connection string and scope sentence write rows straight into the Postgres database already running](/blog/seedfast-vs-tonic-fabricate-1.svg) ## Tonic Fabricate alternative comparison, row by row Every Fabricate figure below was re-read on Tonic's own pages on 10 September 2026, and [Sources](#sources) lists the URLs. Our own numbers come from [seedfa.st/pricing](https://seedfa.st/pricing) and from guides that print the run transcript behind them. Where Fabricate is the better answer, the row says so. | What you are doing | Tonic Fabricate | Seedfast | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Product shape | Hosted chat agent in a browser, with an API and an MCP server alongside it | Command-line tool on your own machine, with an MCP server and a CI step | | Saying what you want | A prompt to the Data Agent ("Create 70 customers. Each customer has 1-3 orders.") | A `--scope` sentence on the command line, writable in any human language | | Where the generated rows land | A Fabricate-hosted database in your project, which you then export | The PostgreSQL database named in your connection string | | Connecting to a database you own | Enterprise license only, and it must allow Fabricate's IP addresses | Any Postgres your machine can reach, on every plan including the free one | | Hosted database size cap | 256 MB on Free, 1 GB on Plus, 5 GB on Enterprise | No such cap, because there is no intermediate database | | **Database engines** _(Fabricate covers more)_ | **PostgreSQL, MySQL, Oracle, Databricks, Snowflake, MongoDB, SQL Server, SQLite** | PostgreSQL today, including Supabase, Neon and Amazon RDS; other SQL engines in development | | **Export formats** _(Fabricate covers more)_ | **CSV, JSON, Parquet, Avro, Iceberg, DOCX, EML, SQLite, MongoDB, Snowflake, Amazon S3** | None, since the seeded database is the output | | **Mock APIs over the generated data** _(Fabricate only)_ | **Yes, advertised on the product page** | Not a feature we offer | | One command in CI after a migration | No command-line tool found in their published docs; API keys are offered for CI | Yes, one step with documented [exit codes](/docs/cicd-database-seeding) | | MCP for AI coding agents | Fabricate MCP; the client talks to the hosted Data Agent, OAuth for Claude and Cursor | `seedfast_run` performs the seed against your database ([setup](/docs/mcp-setup-guide)) | | Exact row count per table | Written into the prompt sentence; no numeric guarantee stated in their docs | Written into the scope; three published runs with the [verification SQL](/docs/exact-row-counts) | | Language of generated values | No locale setting found in Fabricate's docs; handled as prompt wording | Named per table inside the same sentence ([six measured runs](/docs/localized-test-data)) | | References across related tables | Relational generation across the tables in one conversation | Each reference lands on a row that exists, [circular references included](/blog/circular-foreign-key-seed) | | Quality re-check after generation | Validation agent on Plus and Enterprise, not on the free plan | The run reports rows written per table and returns a non-zero exit code when it fails | | Free plan | $0 with $5 of monthly credits; a personal-email signup has no access to the top-tier models | $0 with $5 of monthly credits, $10 on a work-email signup, no card | | Paid entry price | Plus at $29 a month including $25 of credits, then pay-as-you-go | Basic at $16 a month with $32 of credits, Premium at $69 with $180 | | How usage is counted | Tokens, at roughly $0.17 a standard turn and $0.37 a complex turn | Credits drawn by how much data a run generates, with no per-row and no per-token price | | Getting to the first row | Create an account, create a project and a database, start a chat | `npm install -g seedfast`, log in, run one command | Three rows in that table go to Fabricate outright, and none of them is a technicality. If your data does not live in Postgres, engine coverage ends the discussion before the other rows get a vote. Teams whose actual deliverable is a file handed to somebody else, rather than a database that has to be usable by morning, will care about the export list for much the same reason. As for mock APIs standing over generated data, we do not do that at all. ## Where the two tools part company ### The database the rows end up in Nothing on either pricing page makes this difference obvious. Fabricate's generated data lives in a database inside your project, capped by plan, and getting it into your development environment means exporting it or moving to an Enterprise license that permits a connection. Their product page does advertise connecting "directly to your production databases" to model from real schemas and distributions, and the license table is where you find out which plan that sentence belongs to. On our side there is no intermediate copy at any point. You hand the CLI a connection string, it reads the schema sitting behind it, and the rows appear in those tables. Since a security review will ask about the data path anyway, here it is stated plainly rather than buried. The schema definition, meaning table and column names, types and constraints, goes to an AI provider so it can generate values that fit those columns. Row data never leaves your database. If a column name is itself sensitive, that is the detail to settle before adopting either tool. ### One sentence, run again after the migration ```bash seedfast seed --scope "Seed the crm and billing schemas: 200 customers, 600 contacts, 400 addresses, 3 plans, 300 subscriptions, 1,200 invoices with 60% paid, 25% overdue and 15% disputed, 400 support tickets and 1,600 ticket messages." ``` That command is the whole configuration. Because the schema is re-read on every run, a migration that landed this morning is already reflected in this afternoon's seed, and the sentence you wrote last month goes on working without an edit. In a pipeline it becomes one step behind `SEEDFAST_API_KEY`, placed after the migrations. The [exact row counts guide](/docs/exact-row-counts) prints three runs of that exact scope against a fresh schema, each followed by the SQL that counted the result. Run one planned 4,703 records across eight tables, reported 8/8 tables succeeded in 79.59 seconds, and every table that carried a number in the sentence came out carrying that number. We could not find an equivalent published run on Fabricate's side. Their docs describe generated output in prose rather than showing rows, and no page we loaded states an exactness guarantee for counts or shares either way, so treat this as a gap in what is documented rather than a claim about how their agent behaves. What we can do is show ours and let you check it against your own schema. ### Two MCP servers, pointed at different databases Both products ship one, and repeating "we have MCP, they don't" would be false. What differs is where the tool call ends up. Fabricate's MCP server "allows AI clients to communicate directly with the Fabricate Data Agent" so an assistant can create databases, run workflows and download results, with OAuth recommended for Claude and Cursor and API keys for CI. Generation still happens in Fabricate, and the output still starts life in Fabricate's own database. Seedfast's server exposes the same run the CLI performs. An agent calls `seedfast_schema_info` to read what is actually in the database, `seedfast_run` to start the seed, then `seedfast_run_status` until it finishes, and the rows land in your Postgres while the conversation is still open. The [AI agent seeding guide](/docs/ai-agent-seeding) walks a real transcript of that loop with Claude Code. ### What a month costs when the pipeline seeds every build Tonic prices AI work rather than data. Their pricing page defines usage in tokens, "chunks of data like words, numbers, or values in a table," puts a standard turn at approximately $0.17 and a complex one at approximately $0.37, and notes that the free plan's $5 "covers roughly 9 highly complex, multi-step generation sessions." Plus buys $25 of credits for $29 a month and then bills pay-as-you-go. For exploratory work in a browser that model is reasonable and you can stop when the credits run out. Wire the same generator into a build that runs forty times on a busy day and the forecast stops being a number and becomes a range. Enterprise adds a lever we have no answer to, which is bringing your own Anthropic or Amazon Bedrock key. Tonic then stops charging for AI usage and you pay the model provider directly, a genuine advantage for a team already buying inference at volume, and one that also makes the model you choose part of what a run costs. Seedfast sells a monthly plan holding a pool of credits, spent by how much data a run generates. A full session on a schema of about thirty tables comes to roughly $0.60, which is where the eight, fifty and three hundred runs a month on the [pricing page](/pricing) come from. The subscription price stays fixed regardless of build frequency, and the free plan refills $5 every month without asking for a card. ![How Tonic Fabricate and Seedfast meter a run: Fabricate bills per conversation turn, so cost rises with how many turns a session takes, while Seedfast draws credits from a monthly pool priced by how many rows a run generates, not by how long the conversation runs](/blog/seedfast-vs-tonic-fabricate-2.svg) ## Which Tonic Fabricate alternative fits Neither vendor's marketing will answer the question sitting underneath all of this. Is what you need at the end a dataset, or a database that works? Fabricate produces the first one well, across engines we will not reach for a good while and in formats a data team can pass around. Anybody can drive it from a browser tab, which matters more than engineers usually credit. Where a budget stretches to Enterprise, the direct connection and the bring-your-own-key economics put the second artefact within reach too, and at that point you are choosing between two real products rather than between a product and a workaround. We only ever built the second one. The CLI takes your connection string and a sentence saying what the data should represent, then the tables in your own Postgres come out holding rows that satisfy the keys and checks already declared there. Whether that survives contact with your schema takes about two minutes to find out on the [free plan](/subscribe/free), and [how a first seed goes](/docs/seed-your-database) walks the same run in writing if you would rather read it first. Cross-shopping is worth half an hour here. [Tonic.ai alternatives](/compare/tonic-ai-alternatives) splits that vendor's three separate jobs into three shortlists, [Tonic Fabricate vs Mockaroo](/compare/tonic-fabricate-vs-mockaroo) sets both of them against a Postgres CLI, [Syntho](/compare/syntho-alternative) is the answer when the platform has to run inside your own network, and [test data tool pricing](/compare/test-data-tool-pricing) lines the money up on its own. Widen the field past head-to-heads and the [best Postgres test data generator](/blog/best-postgres-test-data-generator) guide ranks the category, while [data seeding tools](/blog/data-seeding-tools) takes the regulated-industry angle. The [comparison hub](/compare) carries the full matrix. ## Frequently asked questions ### What is Tonic Fabricate used for? Teams use it to generate relational and unstructured data from scratch, with no production copy involved. Their documentation states the purpose plainly, "Tonic Fabricate allows you to create data from scratch," and the working surface is a chat agent you brief inside a project, with an API and an MCP server alongside it for programmatic access. Fabricate sits beside Tonic Structural, a separate product that de-identifies real production data, so a shortlist mixing the two is comparing different jobs. ### Is there a Tonic Fabricate alternative that runs from the command line? Seedfast is one. The command takes a connection string and a `--scope` sentence, reads the schema on that run, and writes the rows into the database you pointed it at, with documented exit codes so a build can gate on the result. On Fabricate's side we found an API with a Python library and an npm client in their published docs on 10 September 2026, and no page documenting a command-line tool. ### How much does Tonic Fabricate cost? Free is $0 a month with $5 of monthly credits, Plus is $29 a month including $25 of credits and then pay-as-you-go, and Enterprise is custom with pooled usage. Beyond the included credits, usage is counted in tokens at approximately $0.17 for a standard turn and $0.37 for a complex one (tonic.ai/pricing, read 10 September 2026, and vendor pricing pages move, so re-check before budgeting). The comparable Seedfast numbers are $5 of credits on the free plan, $32 on the $16 Basic plan and $180 on the $69 Premium plan, with no meter running underneath any of them. ### What does the Tonic Fabricate free tier include? Signing up for free gets you $5 of monthly credits, one workspace, a local database capped at 256 MB, basic export options and support through Discord. Two things sit outside that. The validation agent starts at Plus, and connecting to a database of your own requires an Enterprise license, which means the free tier lets you evaluate the agent rather than your schema. Their license page adds that a signup on a personal email address has no access to the top-tier models, while a work email gets a larger AI allowance. Our free plan carries $5 of credits monthly, $10 on a work-email signup, and it works against your own database from the very first command. ### Can Tonic Fabricate seed my own PostgreSQL database? On an Enterprise license, yes. Fabricate connects to Amazon Redshift, Google BigQuery, Microsoft Fabric, MySQL, Oracle, PostgreSQL, Snowflake and SQL Server, the connection requires that Enterprise license, and your database has to allow traffic from Fabricate's IP addresses. Seedfast reaches Postgres on every plan including the free one, since the CLI runs on your own machine with credentials you already hold and nothing needs opening up for it. Below Enterprise, the route from Fabricate to your database runs through an export instead. ### What can Tonic Fabricate do that Seedfast cannot? Quite a lot, and it is worth being specific about it. Fabricate covers eight database engines against our one and exports into a dozen file and warehouse formats. It generates unstructured documents and emails beside the relational rows, stands up mock APIs over what it made, and lets an Enterprise customer supply their own model key so Tonic stops billing for AI usage. It also runs entirely in a browser, which counts for something when the people who need the data are not the people who own a terminal. ## Sources Every Fabricate claim above was read on Tonic's own pages on 10 September 2026: - tonic.ai/pricing for the Free, Plus and Enterprise tiers, the credit amounts, the token definition and the per-turn figures - tonic.ai/products/fabricate for the engine list, export targets, mock APIs, the live database connectivity sentence and the MCP description - docs.tonic.ai/fabricate for "create data from scratch" and the rule-based deprecation notice - Fabricate license plans for the 256 MB / 1 GB / 5 GB caps, workspace counts, the Enterprise gates and the personal-email model restriction - About the Fabricate Data Agent for the chat workflow and the Agent, Plan and Autonomous modes - Prompting the Data Agent to generate data for the "Create 70 customers" prompt example - Adding a database connection to a project for the connectable engines, the Enterprise license requirement and the IP allowance - Using the validation agent for what the validation agent checks and its plan availability - Using the Fabricate API for the Python library, the npm client and the rule-based note - Using the Fabricate MCP server for what MCP clients can do and the OAuth and API-key guidance Seedfast figures come from our own [pricing page](/pricing) and from three guides that print the CLI transcript and the verification queries behind them: [exact row counts](/docs/exact-row-counts), [localized test data](/docs/localized-test-data) and [AI agent seeding](/docs/ai-agent-seeding). **Verified: September 2026.** Tonic ships often and its documentation paths have moved before, so read every quote here as a snapshot of 10 September 2026 and check Tonic's own page whenever a detail is load-bearing for your decision. Verdicts on this page are our opinion, and the figures are the vendors' own. ## Seed the database you already have Comparison pages are cheap to write and easy to slant, which is why every number above carries the page it came from and the date we read it. Ours are the same figures our own guides publish, transcripts and verification queries included. [Seedfast](https://seedfa.st/) still asks for exactly two things, a connection string and a sentence, and what arrives is a Postgres database whose rows already satisfy the keys and constraints its schema declares. No export waits to be moved afterwards. Point it at a schema you know well on the [free plan](/subscribe/free) and judge the rows yourself. Related guides: - [Tonic.ai Alternatives](/compare/tonic-ai-alternatives) for masking, ML synthesis and seeding as three separate shortlists - [Tonic Fabricate vs Mockaroo](/compare/tonic-fabricate-vs-mockaroo) for both of them against a one-command Postgres CLI - [Test Data Tool Pricing](/compare/test-data-tool-pricing) for the money on its own - [CI/CD Database Seeding](/docs/cicd-database-seeding) for the pipeline step, with exit codes --- # Tonic Fabricate vs Mockaroo (vs Seedfast): A Test Data Generator Comparison > Source: https://seedfa.st/compare/tonic-fabricate-vs-mockaroo > Last updated: 2026-09-10 The Tonic Fabricate vs Mockaroo comparison usually starts as a price check, and price is the wrong place to start. The two tools don't really compete. Mockaroo is a web form that exports a flat CSV; Tonic Fabricate is an AI chat-UI that builds a connected, relational dataset. Choosing on row caps or monthly cost skips the only question that decides it: does your test data need to be flat columns, or connected rows with valid foreign keys? This page weighs the two on what a real database demands — foreign keys, schema-awareness, and whether the tool can run in CI — then adds a third shape neither quite covers: a live-schema CLI, for when the data has to land in Postgres with relationships intact. ## Tonic Fabricate vs Mockaroo: which should I use? Use **Mockaroo** for flat, single-table mock data, defining columns in a web form and exporting CSV/JSON/SQL, free up to 1,000 rows per file (as of June 2026, mockaroo.com/pricing). Use **Tonic Fabricate** for AI-driven, schema-aware relational data in a chat UI, on a $29/month Plus plan with per-turn token metering on top (as of July 2026, tonic.ai/pricing). If your data has to satisfy foreign keys in a live Postgres database straight from a CLI, neither fits cleanly, and **[Seedfast](https://seedfa.st/)** is what covers that case, seeding the schema it reads on that run from a single command you can put in a pipeline. ## The two tools answer different questions Mockaroo and Tonic Fabricate get shelved together because both say "test data" on the box. Under the hood they barely overlap. Mockaroo is a column-level generator. You open a web form, add fields, pick a type for each from a large library — name, email, country, a regex, a custom list — and export the result as CSV, JSON, SQL, or Excel. There's nothing to install and you're done in a minute. What Mockaroo won't do is model your database: each table comes out on its own, with no notion that your `orders` rows point at real `customers`. (Its separate REST mock-API product is a frontend tool, and a different conversation entirely.) Tonic Fabricate works the other way around. Tonic.ai's synthetic-data product — distinct from Tonic Structural, their production de-identification platform — Fabricate is schema-aware. You describe the dataset in a chat UI, or point it at a live database to model from existing structure ("Live Connect"), and it generates relationally intact data without copying production. For a large model it can draft a multi-step generation plan before building, and it targets Postgres, MySQL, Oracle, Databricks, and more. What decides it is shape, not quality: flat columns, or a connected relational dataset with valid foreign keys. ## Tonic Fabricate vs Mockaroo vs Seedfast: comparison table The table scores each tool on workflow criteria — the things that decide whether it survives in a real Postgres project — rather than the column-type counts most comparisons lead with. Faker appears as one row because it shows up on these lists; it's a library, not a schema-aware generator. | Workflow criterion | Mockaroo | Tonic Fabricate | Seedfast | Faker (library) | | ------------------------------------ | --------------------------- | -------------------------------------- | ----------------------------------- | ------------------------- | | Primary surface | Web form | AI chat-UI (web) | CLI + MCP | Code library | | Schema-aware | ✗ column-level | ✓ schema-aware | ✓ schema-aware | ✗ you name the columns | | Reads live DB schema on every run | ✗ | partial — connect/describe, then reuse | ✓ reads live Postgres each run | ✗ | | Cross-table foreign-key integrity | ✗ no cross-table FKs | ✓ referentially intact | ✓ no orphaned rows, cycles included | ✗ you wire FKs by hand | | Runs as a CLI / CI step | ✗ export then import | partial — API/SDK + CLI for loading | ✓ one CLI command, CI-native | ✓ runs anywhere code runs | | MCP-native (AI agents seed directly) | ✗ | ✓ (bridges to its hosted agent) | ✓ `seedfast_run` | ✗ | | Multi-database breadth | export to many formats | ✓ Postgres, MySQL, Oracle, Databricks | ✗ Postgres-focused | values only, DB-agnostic | | Pricing model | flat annual (per file size) | monthly + per-turn token metering | monthly, credit pool drawn per run | free / open source | One row there is deliberately a loss for Seedfast — multi-database breadth — and the per-tool detail below covers the trade-offs the checkmarks flatten. **If you already know your data has foreign keys and lives in Postgres,** Seedfast seeds valid relational rows straight from your live schema in one command — [start on the free plan](/subscribe/free) (no card), or [run your first seed](/docs/seed-your-database) in about two minutes. ## When is Mockaroo the right choice? Mockaroo is the right choice when your data is flat and you don't care about relationships. It's genuinely good at what it was built for: a realistic-looking CSV in under a minute, no install, a huge type library, and a free tier that covers a quick demo. For a single throwaway table, a fixture file, or a CSV to paste into a spreadsheet, it's hard to beat — and its no-install web UI reaches a first result faster than a tool you have to install, Seedfast included. That speed-to-first-row is a real advantage, worth being honest about. **Pricing** is published and flat (verified June 2026 against mockaroo.com/pricing). The free tier caps generation at 1,000 rows per file plus 200 API requests per day. Paid tiers scale by file size: Silver $60/year (100,000 rows per file), Gold $500/year (10M rows), and Enterprise $7,500/year (unlimited). If your problem is raw volume, that's the right axis to price on. **Where it stops:** Mockaroo produces flat rows and doesn't connect to your database — it exports, you import. It doesn't enforce cross-table foreign-key integrity, so a relational schema with twenty tables turns into a manual job: export each table, then stitch the foreign keys together by hand. More free rows won't fix that; it's structural. The [Mockaroo alternative](/compare/mockaroo-alternative) comparison covers the relational, CLI-driven path off that wall. ## When is Tonic Fabricate the right choice? Tonic Fabricate is the right choice when you want AI-assisted, schema-aware generation in a polished browser experience and you're comfortable handing the tool your schema — by describing it, connecting once, or pasting it. Fabricate produces relationally intact data without production access, and the chat-driven workflow is pleasant if you like iterating on a prompt in a UI. Its real edge over Seedfast is breadth. Fabricate generates into and exports from far more than Postgres — MySQL, Oracle, Databricks, plus formats like Parquet and Avro — where Seedfast stays Postgres-focused. If your work spans several database engines or needs exotic export targets, a Postgres-only tool simply won't match that reach. **Pricing** is a credit-and-token model (per Tonic's pricing, as of July 2026): a free tier with $5/month in credits for a personal signup or $10/month plus every model for a work signup, a Plus plan at $29/month including $25/month in credits, then per-turn metering — roughly $0.17 for a standard turn and $0.37 for a complex, multi-table one. The structure matters more than the exact cents: every run past your credits adds variable cost. **Where it stops:** Fabricate can connect to a live database, but it runs as a web and chat agent. There's no one-command CLI step that reads your schema and seeds inside a pipeline on every run — its CLI loads already-generated data. The per-turn meter also makes per-pipeline cost harder to forecast than a monthly plan with a credit pool. The full breakdown, with the pricing math, is the [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate) comparison. ## Where does Seedfast fit that neither covers? Seedfast fills the gap between them: relational data, generated from your live schema, run as a CLI command against a real database. [Seedfast](https://seedfa.st/) gives you the relational dataset the way a CI pipeline wants it — one command, a connection string, no UI to click through and no schema to re-describe. ```bash seedfast seed --scope "100 accounts with transactions and varied balances" → Connected to PostgreSQL → Found 34 tables, 67 foreign keys → Generating data... → Done. Seeded 12,847 rows in 6.3s ``` Seedfast connects to a live Postgres database and reads the schema — tables, columns, foreign keys — on every run, which is what that "34 tables, 67 foreign keys" line reports. The result comes out connected and [referentially valid](/blog/referential-integrity), and [circular foreign keys](/blog/circular-foreign-key-seed) resolve too, without hand-written SQL. When a migration adds a column or a table, the next run picks it up with nothing to reconfigure — there's no seed script to maintain. The same command runs as one step in [CI/CD database seeding](/docs/cicd-database-seeding), and the MCP tool (`seedfast_run`) lets AI coding agents like Claude Code, Cursor, and Windsurf seed a database directly. Seedfast's honest limits: it's **Postgres-focused**, so it doesn't target SQL Server, MySQL, or Oracle as first-class databases, and Fabricate's breadth wins there. It generates data from your schema rather than masking a production dataset; if you need a de-identified copy of real rows, that's the [data seeding tools](/blog/data-seeding-tools) category instead. And for a one-off flat CSV, it won't beat Mockaroo's zero-install head start. **Best for:** teams on Postgres (plain PG, Supabase, Neon, RDS) who need valid relational data in CI — from the live schema, without maintaining a seed script, on a credit-pool plan instead of a per-turn meter. ## Seedfast vs Mockaroo: column generator vs live-schema CLI Seedfast and Mockaroo sit on opposite sides of the relational line, and the comparison usually comes up when a Mockaroo-CSV workflow hits a wall. Mockaroo is the right tool while the data stays flat — one table, independent rows, exported to a file. The moment a child row has to reference a parent that actually exists, you're doing the database's job by hand. Seedfast reads your real schema from your Postgres database and produces rows whose foreign keys all point at rows that exist, so there's no manual stitching and no import-order spreadsheet. Switching costs little: you point the CLI at the same database instead of importing a file, describe the scenario instead of defining columns, and drop the export-and-reconcile step entirely. Mockaroo still wins for an instant, no-install, single-table CSV; Seedfast wins the moment the data has to be relational and valid against a live schema. The [Mockaroo alternative](/compare/mockaroo-alternative) page walks through that migration table by table. ## Frequently asked questions ### Does Mockaroo handle foreign keys? Not across tables. Mockaroo is a column-level generator: it produces flat rows one table at a time and doesn't enforce cross-table foreign-key integrity. You can fake a reference inside a single dataset, but nothing guarantees a child row points at a parent that exists in a separate export. So a relational schema leaves you two ways forward. Either you reconcile the keys by hand after the import, or you hand the job to Seedfast, which reads the foreign keys out of the live Postgres schema on the run itself and writes every child row against a parent that already exists. ### How much does Seedfast cost compared to Mockaroo and Tonic Fabricate? Seedfast charges a monthly rate that includes a pool of credits, and each run draws from that pool by how much data it generates: a free plan (no card, $5 of credits a month), then Basic at $16 for $32 of credits and Premium at $69 for $180. No plan limits tables or seeds; the tiers differ by how many credits they include, not by rows or tokens (as of August 2026; see [pricing](/pricing)). Mockaroo bills by file size on an annual plan instead (as of June 2026, mockaroo.com/pricing), and Tonic Fabricate meters per generation turn on top of its plan (as of July 2026, tonic.ai/pricing). ### Do I have to rewrite anything to switch from Mockaroo to Seedfast? There are no seed scripts or factory code to port — a Mockaroo workflow has none to begin with. You point the Seedfast CLI at the same Postgres connection string, describe the scenario instead of defining columns in a form, and drop the export-then-import step. Your schema stays exactly as it is; Seedfast reads it directly on each run. ### Is there a CLI alternative to both Mockaroo and Tonic Fabricate? Yes. Mockaroo is a web form and Tonic Fabricate is a chat UI (with SDK, API, and now an MCP server that hands the request to its hosted, metered agent); both are web-first at the core. Seedfast runs as a CLI command against a live Postgres connection string, reads the schema itself, and runs natively in CI. It's also MCP-native, so AI coding agents can seed a database directly, in place. Fabricate still wins on multi-database breadth if you're not Postgres-only. ### Can Mockaroo or Tonic Fabricate run in a CI pipeline? Only partially. Mockaroo exports a file that some later step has to import, so it isn't a seeding step itself. Tonic Fabricate offers an SDK, an API, and a CLI that loads already-generated data, but the generation happens in its web and chat agent. Seedfast is built to run as one CLI command inside CI, reading the live schema and seeding on every build. ## Pick by the shape of your data, not the brand name The decision comes down to shape. Flat data you need right now? Mockaroo's web form is fast and free up to its cap of 1,000 rows per file (as of June 2026, mockaroo.com/pricing). If you want AI-driven relational generation through a browser and your work spans several database engines, Tonic Fabricate is a real option, and that breadth is the reason to take it (as of July 2026, tonic.ai/pricing). That leaves the third case, and it is where [Seedfast](https://seedfa.st/) sits: relational data in Postgres that has to regenerate in CI with no seed script underneath it. Point the CLI at your live connection, describe the scenario, and connected, referentially valid rows come back from one command. Seedfast charges a monthly plan that includes a pool of credits, $16 or $69 depending on how many runs a month you need, and a free plan sits under both with no table or seed limits on any of them. [Run your first seed](/docs/seed-your-database) in about two minutes, or [see pricing](/pricing). Related guides: - [Best Postgres Test Data Generator](/blog/best-postgres-test-data-generator) — the wider Postgres buyer comparison - [Mockaroo Alternative](/compare/mockaroo-alternative) — the relational, CLI path off a column generator - [Seedfast vs Tonic Fabricate](/compare/seedfast-vs-tonic-fabricate) — the deep one-on-one, with the pricing math - [Data Seeding Tools for Regulated Teams](/blog/data-seeding-tools) — the compliance and masking-vs-synthetic angle --- # Seedfast, a Neosync alternative for the generation half > Source: https://seedfa.st/compare/neosync-alternative > Last updated: 2026-08-19 _If you're searching for a Neosync alternative, you're not the only one. Neosync was acquired by Grow Therapy in September 2025 and the GitHub repo was archived on August 30, 2025. The cloud product is offline, the issue tracker is read-only, and the Hacker News thread noting the archival is where most of the public discussion lives now. If your team is still running Neosync — self-hosted, or on what's left of the cloud — you're depending on a data tool no one is left to patch. This guide is the exit plan — it walks through both paths out, because Neosync did two different jobs and there isn't one tool that replaces both._ ## Key Takeaways - Neosync's team was acqui-hired by Grow Therapy (a mental-health platform) in September 2025 for its HIPAA/privacy engineering, not to keep the product going; the open-source repo at `nucleuscloud/neosync` was archived on August 30, 2025, its final release (v0.5.41) shipped July 11, 2025 — over a year stale — and the hosted cloud service is offline - Neosync is the second major OSS test-data tool to shut down in twelve months — [Snaplet](/compare/snaplet-seed-alternative) went dark in August 2024 — so if you're migrating for the second time in a year, this is a category shift, not one unlucky tool - Neosync did two different jobs — **synthetic data generation** (schema-aware, from scratch) and **production-data anonymization** (copy prod, mask PII, ship to dev/staging). A complete migration has to cover both or admit you're only replacing one side - For the **generation** side, [Seedfast](https://seedfa.st/) is the closest functional match: PostgreSQL-native CLI, schema-aware, FK-valid output, plain-English scope, actively maintained - For the **anonymization** side, Greenmask is the actively-maintained OSS option that does `pg_dump`-compatible masking of production data. Seedfast does not anonymize — generating from schema is a different workflow - The migration is not one command because the product you're leaving wasn't one product. The rest of this guide walks through the generation path in detail and points at anonymization tooling for the other half ## What actually happened to Neosync Neosync was a YC-backed open-source data privacy platform founded in 2023. It shipped two capabilities in one tool: anonymize-and-subset production data into safe dev/staging copies, and generate synthetic data from scratch against a schema. Both capabilities shared the same orchestration layer (a Temporal-based workflow engine) and the same connection / sync abstractions. That unification was the reason teams picked Neosync over picking two separate tools. In September 2025, Grow Therapy — a mental-health platform — announced the acquisition, covered at the time by Pulse 2. It was an acqui-hire — Grow wanted the data-privacy engineering talent for its own HIPAA-scoped systems, not the product to carry on. The GitHub repo was archived on August 30, 2025 — ahead of the formal announcement — and the hosted cloud service was wound down. The repo is read-only, its last tagged release (v0.5.41) shipped July 11, 2025, and the README carries the disclaimer "Neosync has been acquired by Grow Therapy. As a result, this repository is no longer actively maintained." This is the second major developer-facing test-data tool to exit in twelve months: [Snaplet shut its doors](/compare/snaplet-seed-alternative) in August 2024 and its open-source continuation has barely moved since. Teams searching for a Neosync replacement today are often the same teams that picked Neosync as a Snaplet replacement last year. That pattern is the backdrop for the migration choices below. That's where it sits as of mid-2026. Neosync didn't stop working — a self-hosted instance you deployed last year still compiles and still runs — but everything that used to happen around it has stopped. Find a CVE in a transitive dependency, and no patch is coming. The data-corruption bug that only shows up on your particular schema won't get fixed either. And when the ecosystem underneath moves — a PostgreSQL 18 catalog change, a default that flips in Prisma 7, a breaking Temporal SDK release — none of it gets absorbed, and there's no maintainer to escalate to when the breakage reaches your pipeline. An archived data tool won't warn you before it breaks. It just stops absorbing the changes the rest of your stack keeps making, until one of those changes hits something the frozen build can't handle. If you adopted Neosync between 2023 and 2025 because it was open source, YC-backed, and actively shipped, the tool you're running today is not the tool you chose. This article is the migration guide that wasn't published. ## Two use cases, two migration paths Neosync's dual nature is the first thing to be honest about. The way out depends on which half you actually used. **Path A — you used Neosync for synthetic data generation.** You were running Neosync jobs against a schema (often an empty Postgres, a Supabase project, or a Neon branch), describing the dataset you wanted, and getting realistic, FK-valid rows out. You never connected Neosync to a production database, or you did so only as a schema reference. **Path B — you used Neosync for anonymization / subsetting.** You were running Neosync jobs that connected to a real production database, masked PII using transformers, subsetted the data (only the last 30 days, only customer X's rows), and shipped the masked copy to dev or staging. Compliance was part of the reason you picked the tool. **Path C — you used both.** A subset of teams wired Neosync in for both jobs in different workflows. The migration is Path A plus Path B, not one unified move. The rest of this article is primarily Path A, because Seedfast fits Path A exactly. For Path B, skip ahead to the [anonymization section](#if-you-used-neosync-for-anonymization). For Path C, do Path A first with Seedfast — the generation side usually has tighter schedule pressure from CI pipelines — then plan Path B on its own timeline. ## What a Neosync generation replacement has to cover Before swapping tools, it helps to be precise about what Neosync's generation side actually did. Seedfast needs to cover this same surface to be a functional drop-in. **Schema introspection.** Neosync connected to your database, read the tables, columns, data types, and foreign keys, and kept that schema in its workflow state. **Transformer library.** Per-column value generators — around forty of them — covering email, full name, phone, address, postal code, UUID, custom JavaScript, plus an AI transformer for columns where semantic context (product descriptions, free-text notes) mattered. You picked a transformer per column and Neosync produced values matching that shape. **Valid connected data.** When a job produced `orders`, Neosync also produced the `users` and `products` those orders needed, keeping the result [referentially valid](/blog/referential-integrity) instead of littering the database with orphan rows. **A jobs interface (UI + API).** You configured sources, destinations, transformers, and subsetting rules in a web UI. The API underneath was a gRPC/Connect surface. Jobs ran as Temporal workflows — durable, retryable, observable. **A CI story.** Teams running Neosync in CI typically triggered a job run via the API after the migration step, then waited on completion. Seedfast covers schema introspection, valid relational output, per-column value generation, and a scriptable interface through a single CLI command. It does not cover Temporal workflows or production-database sync — those are anonymization concerns and belong on the other side of the split. ## Seedfast: the generation side, in a different shape Seedfast is an AI-powered CLI for PostgreSQL that reads your live schema on every run and generates FK-valid data from a plain-English scope. The functional overlap with Neosync's generation side is direct; the interface is different. ```bash # Install (Homebrew or npm) brew install seedfast-ai/tap/seedfast # or: npm install -g seedfast # Authenticate and point it at your database seedfast login seedfast connect # Generate data seedfast seed --scope "5 workspaces, 20 users across them, 200 documents with realistic revision history" ``` There's no web UI to configure. No per-column transformer selection. No Temporal workflow to wait on. The scope is a plain-English sentence. Seedfast reads the schema, infers semantic intent (workspaces, users, documents — each column's meaning follows from the schema), and generates rows that fit the constraints and the domain. The mechanism is different from Neosync's. Neosync relied on a large library of named transformers plus optional custom-JS transformers to generate each column's values independently. Seedfast uses AI as the primary planner: one model plans the dataset from the scope, generates values that cohere across columns (an e-commerce electronics store produces electronics product names, electronics-appropriate prices, electronics reviewers), and the result comes out connected and valid across every table. The trade-off is determinism: Neosync with seeded transformers could produce the same dataset on the same input; Seedfast generates fresh on each run. Teams that need repeatable datasets for e2e baselines typically run once and `pg_dump` the result, then restore in CI. The other trade-off is that Seedfast is a CLI with a scope-string interface. Teams that liked Neosync's web UI for configuring transformers per column give something up on control granularity. What you get in return is one command against a live schema, with no configuration files, no codegen, and no workflow engine to keep running underneath it. Seedfast runs the whole migration spike on its free plan, which takes no card and refills $5 of credits a month, so you can [install, connect, and seed a real schema](/docs/seed-your-database) before committing to anything. ## If you used Neosync for anonymization Before swapping one anonymizer for another, it's worth asking whether the anonymization pipeline itself is still the right shape. Most teams picked Neosync's anonymization side for one reason: PII rows cannot land in dev. Seedfast's generate-from-schema approach removes the need for a masking step on the dev-refresh path — no production rows in the seeding path, no masking pipeline to maintain, no "is this dump safe to ship" review before every environment refresh. If your real requirement is "dev and staging data that looks and behaves like production without the PII exposure," generation-only retires the anonymization workflow for that use case — one less production credential to rotate, one less PII catalog to keep in sync with schema changes. Seedfast does read your schema — table and column names and types — and analyzes that structure server-side to plan the data; the rows themselves never leave your database. If your requirement is stricter — specific production rows for a bug repro, real distributions for a performance test, regulated-industry auditors asking for masked prod-derived data — you need an active anonymizer. The realistic migration paths: **Greenmask (greenmask.io).** The most active OSS PostgreSQL anonymizer as of early 2026 — the 0.2.x line has been shipping on a regular cadence and the project maintains a visible presence in the PostgreSQL ecosystem. Check its GitHub releases for the latest version. Replaces `pg_dump` with a masked version: connect to prod, specify transformers per column, output a sanitized dump that loads into dev/staging. The workflow is different from Neosync's Temporal-driven sync — it's dump-based rather than continuous — but the job it does is the closest OSS equivalent. Covers most Neosync anonymization use cases without an enterprise contract. **Tonic Structural.** Tonic's enterprise anonymization product. Copies production data, masks PII using generators, subsets, and provisions sanitized environments. The April 2026 Structural Agent launch made this faster to configure — Claude-powered, scans for PII, proposes masking generators in one click. Different pricing model than Neosync (enterprise contract, not open source). Appropriate for teams already operating under enterprise procurement and regulatory scope. **PostgreSQL Anonymizer (pg_anonymizer) 2.0.** A Postgres extension, not a standalone tool. Masks data in-database via view rewriting or column-level rules. Good for teams that want masking without an external job orchestrator. Postgres-only, and requires database superuser access to install. **Basecut (basecut.dev).** Subsetting plus anonymization, CLI-first, PostgreSQL-only. Positioned as a Snaplet successor on the anonymization side. Small team, freemium, active. Pick based on deployment shape. Fully OSS with an active maintainer and no cloud dependency: Greenmask. Already in an enterprise procurement lane: Tonic Structural. Want the masking in the database itself: pg_anonymizer. Want a hosted CLI with a free tier: Basecut. None of those tools also generate synthetic data from scratch at the quality level Neosync did. If you need both, you are running two tools now — that's the honest picture. For the generation side of that pair, Seedfast fills the slot and pairs cleanly with any of the anonymization tools above through the same Postgres connection. ## Neosync alternative compared: Seedfast side-by-side | Capability | Neosync (generation side) | Seedfast | | ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Deployment | Self-hosted Kubernetes + Temporal, or cloud (offline since 2025) | Local CLI + Seedfast-managed planning service | | Config | Jobs configured in web UI or via gRPC/Connect API | One `--scope` string per run, no config file | | Schema awareness | Introspected per job run | Live read on every run | | Generation approach | Library of per-column transformers (~40) plus an AI transformer for semantic columns, each applied independently | AI plans the whole dataset from a scope, values cohere across columns | | Valid related data | Yes | Yes | | Realistic values | Deterministic transformers by default; AI transformer available per-column for context-sensitive fields | AI-native dataset-level planning (column values cohere across the scope) | | Determinism | Seeded transformers reproduce | Fresh per run (pin via `pg_dump` if you need repeatability) | | Workflow engine | Temporal (durable, retryable, observable) | CLI process, one command, exits when done | | PostgreSQL focus | Yes (with MySQL path) | Yes (MySQL planned) | | MCP integration | No | Yes (AI assistants can seed via MCP server) | | Open source | Yes, but no maintainer | No (CLI is closed-source; planning runs server-side) | | Maintenance status | Archived Aug 30, 2025; last release July 11, 2025 | Actively maintained | The rows that matter depend on what you originally liked about Neosync. If it was the web UI and per-column transformer library, Seedfast's scope-string interface is a real change — less granular control, less point-and-click. If it was the "tool reads my schema and produces FK-valid data" property, Seedfast is a direct match with a lighter surface area. ## How to migrate off Neosync (generation side) A typical Neosync-to-Seedfast generation migration takes an afternoon. Here are the concrete steps. ### Step 1. Catalog what your Neosync jobs generated Open the Neosync UI (if your self-host still runs) or the job definitions you have in version control. For each generation job, write down the scenarios in plain English: - 3 workspaces, each with 5-10 members mixing admin / editor / viewer roles - 50 products across 5 categories with realistic prices and inventory counts - 200 orders, average 2 line items each, spread over the last 6 months - One test admin user with a known email for Playwright login This list becomes your Seedfast scopes. The flatter the list, the closer your Neosync job is to something a single `--scope` string can express. If you had deeply nested job compositions in Neosync, you will end up with multiple scope calls in Seedfast, one per logical dataset. ### Step 2. Handle table state separately Neosync's jobs typically included a truncate-target step. Seedfast does not truncate automatically — it writes into whatever state the database is in. Decide where that step lives: ```bash # Simple truncate before seeding psql "$DATABASE_URL" -c "TRUNCATE workspaces, users, products, orders RESTART IDENTITY CASCADE;" # Or recreate schema (CI / ephemeral databases) npx prisma migrate reset --force # (Seed script behaviour is controlled by the `prisma.seed` entry in package.json — # remove that entry in the CI environment if you want `migrate reset` to skip seeding) ``` This is one line in a Makefile or a CI step. It isn't a Seedfast limitation; it's a separation of concerns — the seeder generates, the migration tool handles schema state. ### Step 3. Take down Neosync Self-hosted Kubernetes deployment: ```bash # Stop the Neosync stack kubectl delete -f neosync/ helm uninstall neosync -n neosync kubectl delete namespace neosync # Remove Temporal if it was dedicated to Neosync helm uninstall temporal -n temporal ``` Docker Compose deployment: ```bash cd neosync/ docker compose down --volumes cd .. rm -rf neosync/ ``` If you configured Neosync jobs via API calls in CI, note them — Step 5 replaces them. ### Step 4. Install and connect Seedfast ```bash # Homebrew (macOS/Linux) brew install seedfast-ai/tap/seedfast # Or npm (all platforms) npm install -g seedfast seedfast login # authenticate once per machine seedfast connect # paste your Postgres connection string ``` Seedfast reads the current schema from the Postgres database you point it at. Credentials live in the OS keychain; no per-project config file. The [getting started guide](/docs/seed-your-database) covers the first-run walkthrough. ### Step 5. Replace the Neosync job with a Seedfast scope Where your Neosync job definition (via UI or API) produced: ``` Workspaces: 3 Users: 20 (distributed across workspaces, 2 admins per workspace) Products: 50 (5 categories, price range 10-500) Orders: 200 (2-5 line items each, past 6 months) ``` The Seedfast equivalent is one command: ```bash seedfast seed --scope "3 workspaces. 20 users distributed across them, 2 admins per workspace and the rest mixed editor/viewer. 50 products in 5 categories with prices between 10 and 500. 200 orders, each with 2-5 line items, spread over the last 6 months." ``` For CI runs, exporting `SEEDFAST_API_KEY` puts the CLI into non-interactive mode — the command runs against the provided scope and exits. For interactive runs on a local machine, Seedfast prints a plan summary and waits for confirmation before writing; step through that the first time to sanity-check the dataset shape. For prescriptive fixtures — the one known admin user with a literal email that your Playwright login step references — keep those in a short `fixtures.sql` and run it after Seedfast: ```bash # One command in your CI step, after migrations seedfast seed --scope "..." --output json psql "$DATABASE_URL" -f fixtures.sql ``` Seedfast fills the bulk relational data; the fixture file pins the specific rows your tests expect by literal value. The two layers don't conflict because Seedfast writes through the same Postgres connection. ### Step 6. Rewire CI Where your Neosync pipeline looked something like: ```yaml - name: Trigger Neosync job run: | neosync jobs trigger "$NEOSYNC_JOB_ID" # Poll the Connect API until the run completes — no built-in wait subcommand until curl -s -H "Authorization: Bearer $NEOSYNC_API_TOKEN" \ "$NEOSYNC_API_URL/mgmt.v1alpha1.JobService/GetJobRun" \ -d "{\"jobId\":\"$NEOSYNC_JOB_ID\"}" | grep -q '"status":"COMPLETE"'; do sleep 5 done env: NEOSYNC_API_TOKEN: ${{ secrets.NEOSYNC_API_TOKEN }} NEOSYNC_API_URL: ${{ secrets.NEOSYNC_API_URL }} ``` The Seedfast equivalent: ```yaml - name: Seed database run: npx seedfast seed --scope "e2e baseline: 3 workspaces, 20 users, 200 orders" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: ${{ secrets.DATABASE_URL }} ``` The `SEEDFAST_API_KEY` environment variable puts the CLI into non-interactive mode, so there is no confirmation prompt to suppress and no completion polling to script — the command exits zero on success and non-zero on failure. `--output json` gives machine-readable output for pipeline validation. The full [CI/CD database seeding docs](/docs/cicd-database-seeding) cover GitHub Actions, GitLab CI, and preview-branch workflows. ### Step 7. Verify Run your integration tests against the new dataset. The shape of data Seedfast produces is similar in intent to what Neosync produced (valid, connected, schema-respecting) but exact values differ. Tests that hard-coded specific literals — a Neosync-generated email like `alice.martin@example.com` that happened to be deterministic — need to be updated to query whatever the seed produced rather than assume specific literals. This is usually a one-time fix that surfaces brittle tests worth cleaning up. ## What you gain and what you trade Being honest about trade-offs makes the migration predictable. The gains come first because they're the reason the switch is worth doing. **You gain** low-maintenance schema awareness. No config file to keep in sync, no per-column transformer to update when a migration lands, no workflow to redeploy. Seedfast reads the live schema on each run — the next migration picks itself up automatically. **You gain** domain-aware value generation. Where Neosync transformers generated each column independently, Seedfast plans the dataset as a whole — electronics schemas get electronics product names and electronics-appropriate reviewer personas. For demos and staging, the difference shows up on the first run. **You gain** an actively maintained dependency. When Postgres 18 ships new features, when Prisma 7 changes defaults, when Supabase moves schemas — those fixes land. This was the implicit contract you had with 2023-era Neosync, and it's the contract that disappeared when the repo was archived in August 2025. **You gain** MCP integration. If your team uses Claude Code, Claude Desktop, or any other MCP-aware AI assistant, Seedfast ships an MCP server that lets the assistant seed databases inside the conversation. The [MCP setup guide](/docs/mcp-setup-guide) covers the config. Neosync never shipped this. **You trade** the Neosync web UI. Per-column transformer selection, live job status dashboards, the jobs page — none of that survives the move. If your team used the UI as the primary configuration surface, Seedfast's CLI-only approach is a real change in shape. **You trade** Temporal-backed durability. Neosync jobs were retryable, observable, and survived worker restarts. Seedfast is a CLI process — it runs, completes, and exits. For most generation workflows this is fine (the scope is small enough to complete in one shot), but teams that relied on long-running workflow semantics give that up. **You trade** default determinism for fresh generation on each run. Teams that need repeatable CI datasets run Seedfast once, `pg_dump` the result, and restore from that dump in CI — the same pattern used with every schema-aware generator. It's an extra step, not a blocker. **You trade** MongoDB reach. Neosync's MongoDB support targeted document-store anonymization and sync; Seedfast is PostgreSQL-first and does not seed MongoDB. Most Path A (generation) teams ran Neosync against Postgres schemas and will not notice, but if MongoDB was in scope for your workflow, that use case is not covered today. **You don't gain** anonymization. This is the honest limit — if you're only reading this for the generation half, Seedfast handles it. If you need anonymization too, pair Seedfast with Greenmask, Tonic Structural, or pg_anonymizer per the [section above](#if-you-used-neosync-for-anonymization). ## Frequently asked questions ### Is Neosync actually dead, or could it come back? The repository is archived and read-only, its README states plainly that "this repository is no longer actively maintained," its last release (v0.5.41) shipped July 11, 2025, and the hosted cloud is offline. Grow Therapy acquired the team for internal HIPAA-scoped work, not as a product company, so no one is left who owns the roadmap. A community fork could theoretically form, roughly what happened when Snaplet handed off to the Supabase community, but as of mid-2026 no one has announced one. The safe read is to treat Neosync as a frozen dependency and plan the move now, which for the generation half means Seedfast reading your live Postgres schema on each run, and for the anonymization half means an actively maintained masking tool such as Greenmask. ### Can I keep running self-hosted Neosync and just not migrate? Yes, for now, since nothing forces a self-hosted instance to stop. The risk is a moving target: PostgreSQL 18, Temporal SDK changes, Supabase platform updates, new Prisma versions. When a bug appears that needs a real fix, "wait for a maintainer" is the answer and there is no maintainer. Teams with SLAs, frequent schema changes, or upstream version sensitivity usually find this ceiling low. When it arrives, the generation half moves to Seedfast in an afternoon, since Seedfast reads the schema itself on each run and leaves no workflow engine to redeploy and no per-column transformer to port. ### Does Seedfast work for my stack (Prisma / Drizzle / Supabase / Neon / RDS)? Yes. Seedfast talks to PostgreSQL directly over the wire, so any Postgres-compatible database works — Neon (including branch-per-PR workflows), Supabase (including the [`auth.users` integration pattern](/blog/supabase-db-seed)), AWS RDS, Google Cloud SQL, Azure Postgres, self-hosted Postgres, Railway, Render, and the rest. It is ORM-agnostic — it doesn't care whether Prisma, Drizzle, Kysely, or raw `pg` manages the schema. ### I used Neosync for synthetic data AND anonymization. What's the full migration picture? You are running two tools now. Seedfast handles the generation half. For the anonymization half, the closest active OSS replacement is Greenmask (pg_dump-compatible masking); the enterprise path is Tonic Structural; the in-database path is pg_anonymizer. The [anonymization section](#if-you-used-neosync-for-anonymization) walks through the choices. This is structurally honest — no single tool in the current market covers both halves as cleanly as Neosync did. ### Is Seedfast open source like Neosync was? Not in the same way. The Seedfast CLI is distributed as a binary (Homebrew / npm). The planning step — where AI generates the data plan from your scope — runs server-side. Teams with strict air-gap requirements should evaluate this carefully; teams on normal internet connections will not notice the difference. The [data handling and privacy docs](/privacy-policy) cover exactly what crosses the wire. ### Does Seedfast work with my existing `fixtures.sql` for known test users? Yes, and most teams keep them. Seedfast fills the bulk relational data your tests browse through; your fixture file pins the specific records your tests reference by literal value (the `admin@example.com` Playwright uses, feature flags keyed by `key`, country-code lookup rows). Run Seedfast first for volume, then the fixture file for specifics — they share the same Postgres connection and do not conflict. ### How long does the migration actually take? For a team that used Neosync only for generation, with under 500 lines of job configuration, the move to Seedfast is typically an afternoon. Cataloguing scenarios (Step 1) is usually the longest part, because writing down in plain English what the Neosync jobs were doing takes some thought. The swap itself (Steps 3-6) is maybe two hours. For teams that also used anonymization, add a separate track for evaluating Greenmask or Tonic Structural. ## Take the generation half off a dead dependency If your Neosync jobs were only generating synthetic data, the move finishes in an afternoon: uninstall Neosync, install Seedfast, rewrite the job as a scope string, update one CI step. Seedfast reads the schema live on each run, so nothing survives the move that has to be kept in sync afterward, and there is no workflow engine to redeploy or UI to reconfigure. [Get started with Seedfast](https://seedfa.st/) against any Neon, Supabase, RDS, or self-hosted Postgres connection, on a free plan that covers the whole spike. If you also used Neosync for anonymization, that's a separate evaluation — Greenmask is the closest active OSS match and the [section above](#if-you-used-neosync-for-anonymization) covers the alternatives. ## Related guides - [Snaplet Seed alternative](/compare/snaplet-seed-alternative) — the migration guide for the other shut-down-in-the-same-era tool; same pattern, different product scope - [Data seeding tools: the full comparison](/blog/data-seeding-tools) — category-level breakdown of DIY scripts, web generators, enterprise anonymization, and schema-aware generators - [Best Postgres test data generator](/blog/best-postgres-test-data-generator) — the buyer's comparison of schema-aware generators, web tools, and ORM seeders for Postgres - [Compare all the tools](/compare) — see how the seeding and test-data tools stack up side by side across use cases and features - [Seed file maintenance](/blog/seed-file-maintenance) — why hand-written `seed.ts` and `seed.sql` drift from the schema, and what that drift costs - [What is referential integrity?](/blog/referential-integrity) — the foreign-key property every generation tool has to honor, with the edge cases that break naive seeders - [How to seed a Supabase database](/blog/supabase-db-seed) — the Supabase-specific seeding workflow if your Neosync jobs targeted a Supabase project - [Staging without production data](/blog/staging-without-prod-data) — why copying production into staging is a compliance risk, and how generation retires the anonymization step on the dev-refresh path - [Database seeding in CI/CD](/docs/cicd-database-seeding) — the operational patterns for idempotent, FK-valid seeding in pipelines - [Get started with Seedfast](/docs/seed-your-database) — install, connect to Postgres, run your first schema-aware seed --- # Seedfast, a maintained Snaplet Seed alternative > Source: https://seedfa.st/compare/snaplet-seed-alternative > Last updated: 2026-08-19 _Snaplet shut down on August 31, 2024. The code is open source, but the fork has barely moved. If `@snaplet/seed` still lives in your `package.json` — the package that exports `createSeedClient()` — you're running a dependency that hasn't had a real release in nearly two years. Here's what that actually means and a Snaplet Seed alternative you can migrate to in an afternoon._ > **TL;DR:** `@snaplet/seed` is the data-generation package from Snaplet. `createSeedClient()` returns a typed client that reads your Postgres schema and lets you write `await seed.users((x) => x(10))` to generate connected records. The package still installs from npm, but Snaplet shut down on August 31, 2024 and the community fork hasn't had a meaningful release since v0.98.0 (July 30, 2024). New projects should pick an actively maintained alternative; existing projects can keep running it until a real bug forces the move. ## Key Takeaways - Snaplet Seed was formally shut down on August 31, 2024; the last meaningful release (`@snaplet/seed` v0.98.0) landed on July 30, 2024 — before the sunset announcement — and little has moved since - `createSeedClient()` is `@snaplet/seed`'s main entry point — it returns a typed client built from a `snaplet generate` codegen step; that codegen tooling is what stopped getting fixes after July 2024 - The open-source continuation lives at `github.com/supabase-community/seed`; it is usable, but release activity has slowed and there is no published roadmap - Basecut (which also ranks for "Snaplet alternative") is positioned as a replacement for `@snaplet/snapshot` — the anonymization product. If you were running `@snaplet/seed` instead, the data-generation product, that is a different migration path - A real Snaplet Seed alternative has to replace four things: reading the schema, generating rows that hold together across the schema, the TypeScript `seed.ts` workflow, and the CI/CD hooks — swapping one in isolation leaves the others broken - [Seedfast](https://seedfa.st/) replaces that whole stack without a codegen step or a `seed.config.ts` file: connect to Postgres, describe the scope in plain English, and Seedfast seeds against the schema as it reads it on that run. The free plan takes no card and refills $5 of credits a month ## How `createSeedClient` works (the API people are still searching for) The package is still installable from npm and the API hasn't changed since v0.98.0. If `@snaplet/seed` is already in your codebase, the typical flow is a codegen step plus a `seed.ts` script: ```bash # 1. Install npm install @snaplet/seed @snaplet/copycat # 2. Generate the typed client from your Postgres schema npx @snaplet/seed generate ``` ```typescript // seed.ts import { createSeedClient } from "@snaplet/seed"; const seed = await createSeedClient(); // Optional: truncate every table before reseeding await seed.$resetDatabase(); // Generate 10 users; FKs resolve from the schema-aware typed client await seed.users((x) => x(10)); ``` `createSeedClient()` returns a typed client that knows your tables, columns, foreign keys, and constraints because the codegen step read the live schema. The fluent API (`seed.users((x) => x(10))`, `ctx.connect`, per-record overrides) is what people are searching for when they query `@snaplet/seed createSeedClient example` on Google. A note before you build new code against this: the package is unmaintained as of July 2024. There are no security patches, and the codegen step does not track Postgres 18 or recent Prisma changes. The migration options below are the right answer for new projects. ### Using `@snaplet/seed` with Prisma `@snaplet/seed` reads schema metadata directly from Postgres, but it also accepts a Prisma schema as the input via `seed.config.ts`: ```typescript // seed.config.ts import { defineConfig } from "@snaplet/seed/config"; export default defineConfig({ adapter: () => { // Postgres connection string (direct, not pooled) return { // ... }; }, select: ["public.*"], }); ``` The Prisma path was the most common adoption in 2023–2024. With `@snaplet/seed` shipping no further releases, projects on Prisma 6+ usually pin the existing version and accept that schema introspection will not pick up newer Prisma features. ### Installing `@snaplet/seed` from npm in 2026 The package is still published at `@snaplet/seed`. The latest version is **0.98.0** (July 30, 2024); no further versions have shipped to npm under that name. The community fork continues under the same name in the `supabase-community/seed` repository but has not republished to npm with a new version number. If you `npm install @snaplet/seed` today, you receive v0.98.0. ## What actually happened to Snaplet Snaplet was a YC-backed company building developer tools for realistic test data since 2021. It shipped two products — `@snaplet/snapshot` (snapshot and anonymize production) and `@snaplet/seed` (generate data from schema) — both of which found genuine traction in the PostgreSQL and Supabase ecosystem. In summer 2024, the founders announced the shutdown with an August 31 cutoff for the hosted service. The tools were open-sourced, the team joined Supabase, and maintenance of `@snaplet/seed` was handed to the Supabase community under `github.com/supabase-community/seed`, per Supabase's own post announcing the move. That part is fine. Snaplet could have simply archived everything; instead they open-sourced it and found a maintainer. What's less fine is what happened next. The last meaningful release is v0.98.0, shipped July 30, 2024 — before the shutdown announcement. Since the community handover, activity has been issues and minor fixes, not feature work. The repo is alive; the roadmap isn't. If your team adopted `@snaplet/seed` between 2022 and 2024, the tool you evaluated — one with an active company, paid support, and a feature roadmap — is not the tool you're running today. That gap is the whole reason this article exists. ## Your options if you're still on @snaplet/seed Teams currently running `@snaplet/seed` (or its community fork) typically have four paths. All of them are real; three of them are slow. **Stay on the community fork.** It works. For schemas that aren't changing much and don't need new features, there's no urgency. But you're depending on volunteer bandwidth to keep pace with PostgreSQL 18, Supabase schema changes, and whatever breaks in Prisma 7. If you hit a bug that needs a real fix, "wait for a maintainer" is the answer. **Rewrite to plain Faker + manual seeders.** Strip the dependency, drop back to `@faker-js/faker` plus hand-written `INSERT` statements or ORM factories. This is the "return to 2019" path. You own every line of it, which is good, but you also own every foreign key you have to wire by hand — which is exactly the pain `@snaplet/seed` was built to eliminate. For anything beyond 10 tables, this trade is rarely worth it. **Move to a framework's native seeder.** If you use Prisma, Laravel, or EF Core, their built-in seed mechanisms exist. But the core constraint is identical to the Faker rewrite: data is defined manually, relationships are wired by hand, and every migration breaks something. [ORM seeders don't scale past a weekly-migration cadence](/blog/database-seeding) either — see the [per-ORM seeder comparison](/blog/database-seeder) if you want to weigh the syntax differences before committing. **Migrate to an active schema-aware alternative.** Keep the "tool reads your schema, produces valid related data" property, ditch the dead dependency. This is the path that preserves the reason you adopted Snaplet in the first place. Seedfast is the closest functional match for `@snaplet/seed` users: CLI-first, schema-aware, actively developed, with a free plan to start on. Tonic Fabricate is another option, priced for enterprise procurement. Greenmask targets anonymization more than generation, so it fits the `@snaplet/snapshot` use case, not the seed-generation one. The rest of this guide walks through the fourth path using Seedfast, since it's the one most similar in shape to what `@snaplet/seed` was doing. ## What a Snaplet Seed alternative has to replace Before the migration steps, it's worth being precise about what `@snaplet/seed` actually did so you know what to look for in any replacement. **Schema introspection.** Snaplet Seed ran a codegen step (`npx @snaplet/seed generate`) that read your Postgres schema and produced a typed client. That client knew your tables, columns, foreign keys, and constraints at compile time. **A typed scope API.** You wrote TypeScript against that generated client — `await seed.users((x) => x(10))` — and Snaplet Seed would create ten users with whatever dependencies they needed, deterministically via Copycat. **Valid connected data.** Ask `@snaplet/seed` for orders and it produced the users, products, and line items those orders needed, so what came back was connected rather than a pile of orphaned rows. **AI plugin.** Later versions added an `ai` plugin that could pull realistic values from OpenAI or Groq for columns that Copycat couldn't infer well (addresses, product names, free-text fields). **A CI story.** Running `tsx seed.ts` in a pipeline after migrations was the default. A real replacement has to cover all five. Swapping in Faker handles the value generation but leaves you wiring every relationship by hand again. Swapping in Tonic handles schema awareness but moves you into enterprise procurement. The criterion is whether the replacement does the full job in a single tool. ## Seedfast: the same job in a different shape Seedfast is an AI-powered CLI for PostgreSQL that reads your live schema on every run and generates connected test data. The functional overlap with `@snaplet/seed` is substantial; the interface is different. ```bash # Install (Homebrew or npm) brew install seedfast-ai/tap/seedfast # or: npm install -g seedfast # Authenticate and point it at your database seedfast login seedfast connect # Generate data seedfast seed --scope "10 users with 5 orders each across 3 product categories" ``` There's no codegen step. No `seed.config.ts`. No typed client to regenerate after every migration. The scope is a plain-English sentence instead of a TypeScript fluent API. When the schema changes, the next `seedfast seed` picks it up — no artifact to keep in sync. The mechanism is different from Snaplet too. Where `@snaplet/seed` relied on Copycat for deterministic value generation plus an optional AI plugin, Seedfast uses AI as the primary planner: it reads the schema, infers the semantic intent from your `--scope`, and generates data that fits both the constraints and the domain. That makes the output more contextually realistic for domain-heavy schemas (healthcare, e-commerce, fintech). The trade-off is determinism: where Copycat reproduced the same dataset from the same inputs by design, Seedfast generates fresh data on each run. Teams that need repeatable datasets for e2e baselines usually pin a specific run's output (via `pg_dump` or the dashboard) rather than regenerating. ## @snaplet/seed vs Seedfast: side-by-side | Capability | `@snaplet/seed` (community fork) | Seedfast | | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | Installation | `npm install @snaplet/seed` + codegen | `npm install -g seedfast` or Homebrew binary | | Config file | `seed.config.ts` required | None | | Schema awareness | Generated client (regenerated per schema change) | Live read on every run | | Scope API | Typed TypeScript fluent API | Plain-English `--scope` flag | | Valid related data | Yes | Yes | | Realistic values | Copycat (deterministic) + optional AI plugin | AI-native, domain-aware | | Determinism | Seed-based, deterministic by default | Fresh generation per run (pin via `pg_dump`) | | ORM coupling | TypeScript-first; best fit for Node/Prisma | ORM-agnostic (reads Postgres directly) | | CI/CD integration | `tsx seed.ts` + env vars | `npx seedfast seed --scope "…" --output json` + `SEEDFAST_API_KEY` | | Maintenance status | Community fork, limited release activity since July 2024 | Actively maintained (see public [getting started guide](/docs/seed-your-database) and release cadence) | | MCP integration | No | Yes (AI assistants can seed via MCP server) | | PostgreSQL focus | Yes (with experimental SQLite/MySQL paths that shipped before shutdown) | Yes (MySQL planned) | | Runtime dependency | Local-only (no network after codegen) | Requires Seedfast account + internet connection (planning runs server-side) | The rows to pay attention to depend on what you originally liked about Snaplet. If it was the typed TypeScript API, Seedfast's plain-English scope is a genuine change in shape, and you give something up. If it was "the tool reads my schema and keeps the relationships valid", Seedfast is a direct match with a different surface. > **Evaluating the switch?** Seedfast's free plan is enough for a full migration spike — install, connect your dev database, run one seed. [Get started in under five minutes](/docs/seed-your-database). ## Migration guide from @snaplet/seed to Seedfast Below is a concrete migration for a typical `@snaplet/seed` setup. Most teams finish this in under an hour. ### Step 1. Catalog what the current seed file does Before replacing anything, read your `seed.ts` and write down the scenarios in plain English. Something like: - 20 users across 3 teams with admin/editor/viewer role distribution - 50 products in 5 categories with realistic prices - 100 orders, average 2 line items each, over the last 6 months - One test admin user with a known email for e2e login This list becomes your Seedfast scopes. The easier this list is to write, the closer your current `seed.ts` is to something a `--scope` string can express directly. ### Step 2. Handle the table-reset step separately Snaplet's `seed.$resetDatabase()` truncated tables as part of the seed call. Seedfast does not do this automatically — it writes into whatever state the database is in. If your old workflow relied on `$resetDatabase()`, decide where that responsibility now lives: ```bash # Simple path: truncate before seeding psql "$DATABASE_URL" -c "TRUNCATE users, teams, products, orders RESTART IDENTITY CASCADE;" # Or recreate the schema (CI / ephemeral DBs): npx prisma migrate reset --force --skip-seed # Prisma example ``` This is typically one line in a CI workflow or a make target. It is not a limitation so much as a separation of concerns — the seeder generates, the migration tool handles schema state. ### Step 3. Remove the Snaplet dependency ```bash npm uninstall @snaplet/seed @snaplet/copycat rm -f seed.ts seed.config.ts rm -rf .snaplet ``` If your CI scripts reference `@snaplet/seed generate` or `tsx seed.ts`, note them — Step 5 replaces these. ### Step 4. Install and connect Seedfast ```bash # Homebrew (macOS/Linux) brew install seedfast-ai/tap/seedfast # Or npm (all platforms) npm install -g seedfast seedfast login # authenticate (one-time per machine) seedfast connect # paste your Postgres connection string ``` Seedfast reads the current schema from the Postgres database you point it at. There is no per-project config file; credentials are stored in the OS keychain. See the [getting started guide](/docs/seed-your-database) for the full first-run walkthrough. ### Step 5. Replace the seed calls Where `seed.ts` had: ```typescript // Old @snaplet/seed import { createSeedClient } from "@snaplet/seed"; const seed = await createSeedClient(); await seed.$resetDatabase(); await seed.teams((x) => x(3)); await seed.users((x) => x(20, (ctx) => ({ role: ctx.index < 2 ? "admin" : "editor", })), ); await seed.products((x) => x(50)); await seed.orders((x) => x(100)); ``` The Seedfast equivalent is a single command: ```bash seedfast seed --scope "3 teams. 20 users across those teams, 2 admins and the rest editors. 50 products. 100 orders, each with 2-5 line items, spread over the last 6 months." ``` When `--scope` is provided, Seedfast auto-approves the generated plan without prompting — which is what you want for scripts and CI. Where `seed.ts` used hierarchical contexts (`ctx.connect`, per-record overrides like "this specific user owns this specific order"), the plain-English scope is not an exact substitute. The pattern that works: let Seedfast generate the bulk data, then insert prescriptive fixtures with a short SQL follow-up. Concrete example. Snaplet wiring a known admin user to a specific team: ```typescript // Old @snaplet/seed — prescriptive wiring via ctx.connect await seed.teams((x) => x(1, { name: "QA Team" })); await seed.users((x) => x(1, (ctx) => ({ email: "admin@example.com", team: ctx.teams[0], role: "admin", })), ); ``` Seedfast equivalent — generate the background, then pin the fixture: ```bash seedfast seed --scope "3 teams with 5 users each" ``` ```sql -- Pin the e2e login fixture afterward INSERT INTO teams (id, name) VALUES ('qa-team-1', 'QA Team'); INSERT INTO users (id, email, team_id, role) VALUES ('admin-1', 'admin@example.com', 'qa-team-1', 'admin'); ``` Two files instead of one, but both are short and the SQL file is stable — it only changes when your e2e login expectations change, not when the schema shifts. Most teams find that 90% of what `seed.ts` did was generic "make plausible data" (perfect for `--scope`) and 10% was "this exact record for this exact test" (the SQL file). If that ratio is different for your codebase — say, heavy ctx.connect use across many tables — map it with the checklist from Step 1 before committing to the migration. If the scope describes more data than the credits left on your plan can cover, the CLI exits with a non-zero status in non-interactive mode rather than seeding halfway. Split the scope across several `seedfast seed` calls, or narrow it to the tables the run actually needs. ### Step 6. Rewire CI/CD Wherever your pipeline ran `tsx seed.ts`, replace it with `seedfast`. The shape is identical — a single step after migrations, before tests: ```yaml # Before - name: Seed database run: | npx @snaplet/seed generate tsx seed.ts env: DATABASE_URL: ${{ secrets.DATABASE_URL }} # After - name: Seed database run: npx seedfast seed --scope "e2e baseline: 3 teams, 20 users, 100 orders" --output json env: SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }} SEEDFAST_DSN: ${{ secrets.SEEDFAST_DSN }} ``` The CI/CD CLI uses `SEEDFAST_API_KEY` for non-interactive auth and reads the connection string from `SEEDFAST_DSN` (or `DATABASE_URL` as a fallback). `--output json` is machine-readable for pipeline validation. The [CI/CD database seeding docs](/docs/cicd-database-seeding) cover GitLab CI, CircleCI, and ephemeral preview environments in more detail; [why synthetic data belongs in CI](/blog/synthetic-data-ci-cd) covers the case for generating it in the pipeline rather than carrying a fixture dump around. ### Step 7. Verify Run your integration tests against the new dataset. The shape of data Seedfast produces is similar in spirit to what `@snaplet/seed` produced (valid, connected, schema-respecting) but the exact values will differ. Tests that hard-coded Copycat-generated values — an email like `alice.smith@example.com` that happened to be deterministic — need to be updated to query what the seed produced rather than assume specific literals. This is a one-time fix and usually reveals brittle tests worth cleaning up anyway. ## What you lose and what you gain Being honest about the trade-offs makes the migration predictable. Two things genuinely go away. The typed TypeScript fluent API is the big one: if your team enjoyed writing `await seed.users((x) => x(10, ctx => ({ team_id: ctx.teams[0].id })))` and getting IDE autocomplete for the entire schema, that muscle memory doesn't transfer, because Seedfast's interface is a CLI and a scope string rather than a code API, so you trade compile-time schema access for having no code artifact to maintain. Default determinism goes with it: Copycat produced the same fake name from the same input every time, where Seedfast generates fresh data on each run, so a repeatable dataset for an e2e baseline means pinning the output with a `pg_dump` and restoring from that in CI, an extra step but not a blocker. What you get back is why the switch is worth it. Schema awareness stops being a maintenance chore: no `snaplet generate` step drifting from the real schema, no `seed.config.ts` to keep in sync, no "regenerate the client" line in onboarding docs. Seedfast reads the live schema on each run and tracks migrations automatically, so you still maintain the scope strings you care about and the short SQL file of prescriptive fixtures from Step 5, but there's no typed client and no codegen artifact (see [why static seed files break](/blog/seed-file-maintenance) for the broader case). The data gets more domain-aware too: where Copycat produced lexically plausible strings, AI-driven generation produces strings that match the semantic scope, so an electronics store gets electronics product names and a healthcare schema gets medication-adjacent fields, and for demos and staging the difference shows up immediately. You also inherit an actively maintained dependency: when Postgres 18 ships a new feature, when Supabase changes a branching default, when Prisma 7 reshuffles the seed command, those fixes land — the original contract with commercial Snaplet, and the one that disappeared in August 2024. There's one more gain Snaplet never offered: MCP integration. If your team uses Claude Code, Claude Desktop, or any other MCP-aware AI assistant, Seedfast ships an MCP server that lets the assistant seed databases inside the conversation; the [MCP setup guide](/docs/mcp-setup-guide) covers the config. Snaplet never shipped this and, predating the protocol, probably never would have. ## Frequently asked questions ### Is Snaplet Seed actually dead, or just "community maintained"? It's the second, but in a way that behaves like the first for most teams. The code runs; the GitHub issues get some attention. But the last substantive release is from July 2024 and there is no visible roadmap. Depending on that for a critical CI step means accepting that unplanned fixes will take as long as a volunteer has free time. Teams with SLAs or frequent schema changes usually find that ceiling low. The ones that move mostly land on Seedfast, because reading the live schema on each run is the property they adopted Snaplet for in the first place, minus the generated client that goes stale between migrations. ### Can I keep using `@snaplet/seed` and ignore this? Yes, as long as your schema is stable, your Postgres version isn't changing, and you don't hit a bug that needs a real fix. The risk is that all three of those are moving targets. Most teams that open `@snaplet/seed` issues in 2026 find a thread waiting for a maintainer. That's a fine situation until it isn't. ### Does Seedfast work with Supabase the way `@snaplet/seed` did? Yes. Seedfast reads PostgreSQL directly, so it works against any Supabase project — including branching. You can seed a parent branch once and have preview branches inherit the data. Unlike `@snaplet/seed`, there's no Supabase-specific adapter needed; it's just a Postgres connection string. The same shape works for any hosted Postgres (Neon, RDS, self-hosted). ### Is Seedfast deterministic? My CI needs the same data every run. By default, no — scope strings produce fresh data on each run. That is a genuine difference from Copycat-backed Snaplet. The practical workaround most teams adopt is to run the seed once, `pg_dump` the result, and restore from that dump in CI. This gives you an identical dataset every pipeline run and also makes CI faster (restoring a dump beats regenerating). If determinism is a hard requirement for your workflow, factor that into your evaluation. ### Isn't Basecut already the "Snaplet alternative"? How is Seedfast different? Basecut is a replacement for `@snaplet/snapshot` — the product that took anonymized copies of your production database. Seedfast is a replacement for `@snaplet/seed` — the product that generated fresh data from the schema. If you were snapshotting prod and masking it, Basecut is the direct path. If you were writing `seed.ts` to populate empty dev and CI databases, Seedfast is. They solve adjacent but distinct problems, which is why both can rank for the same broad query. ### What about anonymization? `@snaplet/snapshot` did that and Seedfast doesn't. Correct — Seedfast generates synthetic data; it doesn't anonymize production data. If your workflow was "snapshot prod, mask it, ship it to dev," Seedfast is the wrong shape. Look at Greenmask or Tonic Structural for that. Most teams using `@snaplet/seed` were on the generation path, not the snapshot path — if you fall in that category, Seedfast is the direct swap. ### Does `@snaplet/seed` work with MongoDB? No — `@snaplet/seed` was Postgres-first with experimental SQLite and MySQL paths that shipped before the August 2024 shutdown. MongoDB was never officially supported and the community fork has not added it. If you need a MongoDB-aware data generator, the closest active options are mongoose factory libraries or a hand-rolled `mongo-seeding` script. Seedfast targets Postgres and is not a MongoDB replacement either. ### Can I run both in parallel during migration? Yes, and it's often the right approach for larger codebases. Keep `@snaplet/seed` running in CI while you stand up Seedfast on a branch. Compare the datasets they produce against a known test suite. Cut over once the tests pass against the Seedfast-generated data. The cost is one extra CI step for a week or two. ## Retire seed.ts in an afternoon If your `seed.ts` is under 200 lines, the swap is usually under an hour: uninstall the package, install Seedfast, rewrite the scope as a sentence, update one CI step. A longer file breaks up cleanly, with a scope string carrying the 90% that is generic data and a short SQL insert or ORM call pinning the 10% that has to be a specific record. What you no longer own either way is a code artifact that has to stay in lockstep with the schema, because Seedfast reads the schema itself every time it runs. Getting to a seeded database takes about five minutes, and the free plan asks for no card. [Get started with Seedfast](https://seedfa.st/) or read the [installation walkthrough](/docs/seed-your-database). If you're still comparing options, the [data seeding tools comparison](/blog/data-seeding-tools), the [best Postgres test data generator](/blog/best-postgres-test-data-generator) buyer's guide, and the [test data generation methods comparison](/blog/test-data-generation) cover the broader field of Faker, ORM seeders, enterprise anonymization, and schema-aware generators, so you can pick what fits your situation rather than what matches a single blog post. Snaplet isn't the only tool that left its self-serve users stranded, either; the [Gretel alternative](/compare/gretel-alternative) guide walks the same migration for anyone NVIDIA's acquisition displaced. If `seed.ts` was the only Snaplet artifact you kept around, the [Postgres seed script anatomy](/blog/postgres-seed-script) is the cleanest place to land afterward. ======================================================================== # Section: Free tools ======================================================================== # PostgreSQL Formatter > Source: https://seedfa.st/tools/postgresql-formatter > Last updated: 2026-09-14 This PostgreSQL formatter indents a query, sets the keyword case and puts every clause on its own line, inside your browser. Minify collapses it back onto one line. ## What it does and how to use it The box above opens with a query that carries most of what people come here to check, a CTE, `DISTINCT ON`, a window function, a `$1` placeholder, two jsonb operators and an `ON CONFLICT ... DO UPDATE`. Edit it in place or paste over it and the result pane follows a moment after you stop typing. Ctrl+Enter, or Cmd+Enter on a Mac, will format PostgreSQL query text at once instead of waiting. The Keywords and Indent controls underneath re-run the formatter on whatever is already there, and the two tabs at the top choose between laying the statement out and collapsing it. When the library cannot read your input, its message lands under the result and your text stays put. ## How to format a PostgreSQL query without rewriting it Indentation earns its keep the moment you read a query beside its plan, because `EXPLAIN` prints one node per line, and a statement laid out the same way lets you match the sequential scan you are worried about to the table it came from without counting parentheses. Postgres syntax that trips generic formatters mostly survives, with one quirk worth knowing. In the sample above, `DISTINCT ON (o.customer_id)` keeps its text, though the line break falls after `DISTINCT` rather than after the closing parenthesis. Casts stay glued to what they cast, so `created_at::date` never returns as `created_at :: date`, and `->>`, `@>` and `#>>` are spaced as the operators they are. For all that, nothing here checks whether a statement is valid, so `select from where` comes back neatly indented. The [syntax checker](/tools/postgres-syntax-checker) runs the real PostgreSQL grammar and reports where it stopped. ## Minify a PostgreSQL query for one line The Minify tab is a PostgreSQL SQL minifier written for this page, since the formatting library has no such mode. It walks the text once, collapses every run of whitespace to a single space and drops both comment forms, the `--` line and the `/* */` block, counting depth on the block form because PostgreSQL lets those nest. Anything quoted is copied through byte for byte. A string literal keeps its doubled spaces, a `"Quoted Ident"` keeps its capitals and its spacing, and a dollar-quoted body keeps its line breaks along with its own comments, which is what a `DO` block inside a migration needs. A `$1` is read as a parameter rather than as the opening of a `$tag$`. Here is the run behind those claims, taken from the widget code itself. This input, with two comments, a literal carrying doubled spaces and two placeholders, ```sql -- nightly rollup SELECT o.id, 'two spaces kept' AS note, "Quoted Ident" /* block /* nested */ comment */ FROM orders o WHERE o.created_at >= $1::date -- param stays AND o.payload ->> 'channel' = $2; ``` comes back as one line with both comments gone and the literal untouched. ```sql SELECT o.id, 'two spaces kept' AS note, "Quoted Ident" FROM orders o WHERE o.created_at >= $1::date AND o.payload ->> 'channel' = $2; ``` A `DO $$ ... $$` block keeps its shape while the statement after it collapses onto the same line as the closing `$$;`, so a minified migration with a function body in it still spans several lines, and that is correct rather than a bug. Where a comment was removed a space is left behind, which stops `select 1 --note` on one line and `from t` on the next from welding into `1from`. ## Format a query copied out of a Prisma or Drizzle log The usual reason to want a postgres sql formatter online rather than a plugin in the editor is a query that never lived in the editor. Prisma logs its SQL with every identifier quoted and schema-qualified, `"public"."User"."createdAt"` and so on, with `$1`, `$2` and `$3` standing where the values went. Drizzle's log looks much the same with shorter names. Both paste in as they are, because the formatter reads `$1` as a numbered placeholder rather than as broken syntax, so nothing needs substituting first, and a quoted identifier passes through with its case intact, so `"User"` stays `"User"` whatever the keyword setting says. ```sql SELECT "public"."User"."id", "public"."User"."email" FROM "public"."User" WHERE ( "public"."User"."id" = $1 AND "public"."User"."deletedAt" IS NULL ) ORDER BY "public"."User"."createdAt" DESC LIMIT $2 OFFSET $3 ``` That is the Prisma shape after Format with keywords in upper case. The parentheses the ORM wrapped around the `WHERE` open onto lines of their own, which is the one thing worth deleting by hand before the query goes into a bug report. The `$1` markers survive the trip, because the panel reads a numbered placeholder as a parameter rather than as text to be filled in, so what comes out is still the statement Prisma sent. ## Keyword case: upper, lower, as typed Keywords is the first control under the panel and has three settings. UPPERCASE writes `SELECT`, `FROM` and `IS NULL` in capitals, lowercase writes them small, and As typed leaves every keyword exactly as it arrived, so a `Select` stays `Select` and a mixed-case query stays mixed. Only words the dialect knows as keywords are touched. A column called `from_date` is an identifier and keeps its case under every setting, a string literal that happens to read `'select from where'` is data and is never rewritten, and the quoted `"select"` is an identifier as well, which is what the double quotes are for. Nothing in the server cares which you pick. PostgreSQL reads key words without regard to case and folds unquoted identifiers to lower case, so the three settings produce three texts the server reads as the same statement. Upper is the default here because the manual's own examples use it, and because a capitalised keyword is the fastest way to find the clause boundaries in a query someone else wrote. ## Frequently asked questions ### Does it handle CTEs and window functions? The dialect running here knows both. WITH, WITH RECURSIVE, OVER (PARTITION BY ... ORDER BY ...), ON CONFLICT ... DO UPDATE and RETURNING are indented as Postgres syntax rather than guessed at. ### What happens to a pl/pgSQL body? Text between dollar quotes is a string literal to the formatter, so a DO block comes back with its line breaks, indentation and comments intact. Only the SQL around it is reformatted. ### Does my SQL leave the browser? No. The library loads into this page and your query is processed in the tab you are reading. Open the network panel, press Ctrl+Enter, and watch nothing go out. ### Does it change the meaning of my query? No, it touches whitespace and the case of keywords, and nothing else. Identifiers, string literals, numbers, casts, operators, comments and placeholders are copied through as they came, and Minify removes comments on top of that. A statement that was wrong stays wrong in the same way, which is what the syntax checker is for. ### Can it format a whole migration with several statements? Yes, and the statements split on the semicolon and come back separated by a blank line, so a file with a CREATE TYPE, an ALTER TABLE and an INSERT reads as three blocks. One quirk shows up in exactly that file. ENUM in CREATE TYPE ... AS ENUM (...) is not on the library's keyword list, so it keeps the case you typed and sits against its parenthesis like a function call. ### How is this different from pgFormatter? pgFormatter is a Perl program you install and run on your own machine. A JavaScript library, sql-formatter, does the work here, so the layout differs in places. Neither changes what a query does. ### What happens to comments? Format leaves them in place, a -- comment staying on its own line, a trailing one at the end of the clause it was written after, and a /* */ block moving onto a line of its own. Minify drops both forms, except inside a dollar-quoted body, where the text is a string literal and everything in it survives. --- # PostgreSQL Syntax Checker > Source: https://seedfa.st/tools/postgres-syntax-checker > Last updated: 2026-09-14 This postgres syntax checker parses SQL with the PostgreSQL parser itself, compiled to WebAssembly and running in your browser, so it needs neither a database nor a connection string. A rejected statement comes back carrying the token the grammar stopped on, with its line and column. ## What it does and how to use it Paste one statement or a whole migration file and press Check, or run it from the keyboard with Ctrl+Enter (Cmd+Enter on a Mac). Either the grammar accepts it and the tool counts the statements, or it stops and hands back the parser's own message, the position, and the offending line with a caret under the token. The parser is libpg-query, the PostgreSQL C parser compiled to WebAssembly. About a megabyte arrives on your first Check rather than on page load, so that run is the slow one. It is one grammar, PostgreSQL 17.7, and the number 170007 printed under every result is that version the way the server writes it. A clause that arrived in PostgreSQL 18, such as `virtual` on a generated column, is rejected here with `syntax error at or near "virtual"` even though an 18 server takes it, and there is no picker to change that. As a postgresql syntax validator online it covers the 17 grammar and nothing newer; the surest way to check postgres query syntax for another release is still that release's own server. ## Why you see "syntax error at or near" That message names the first token the grammar could not place, which is often one past the real mistake, so the fix usually sits to the left of the caret. The headings below are the exact strings the parser prints, and every example was run through the parser this page ships, libpg-query 17.7.4, with the message copied from what it returned. ### syntax error at or near "user" Reserved words in a name position are the commonest cause. `create table user (id int);` stops at `user`, column 14, and so does `insert into user (id) values (1);`. The word is reserved because on its own it is a function returning the current role, which is also why `select * from user;` parses without complaint. The same fate meets `order`, so `select id, order from line_items;` stops at `order`. Double quotes turn the word back into a name, `create table "user" (id int);`, and every later reference has to carry the quotes too. ### syntax error at end of input Here the grammar ran out of text before it ran out of expectations. `select * from posts where id in (1, 2` is missing its closing parenthesis and `select * from posts where id = 1 and` its right-hand operand, and both report a position one past the last character. A bare `insert into posts (id) values` does the same. The caret has nowhere to point but the end, so the cure is to look at what the last token promised and supply it. ### unterminated quoted string at or near An opening quote with no partner carries the scanner to the end of the input. `select * from posts where title = 'Hello;` gives `unterminated quoted string at or near "'Hello;"`, and the quoted fragment runs from that first quote to the last character because the close was never found. The sneakier version is an apostrophe inside the string. `insert into posts (title) values ('it's here');` closes the string after `it`, so the message is `syntax error at or near "s"` instead, with the leftover quote dangling behind it. Doubling the apostrophe, `'it''s here'`, is the fix, and a dollar-quoted `$$it's here$$` sidesteps the problem entirely; the [escape string tool](/tools/postgres-escape-string) writes both forms. A missing closing double quote reports `unterminated quoted identifier` in the same shape. ### syntax error at or near "-" Outside quotes a hyphen is the subtraction operator. `select * from my-table;` stops at the hyphen, column 17, because the grammar has read `my - table` and is now looking at an expression where a table name should be. Quoting the identifier, `"my-table"`, keeps the hyphen inside the name. The same happens to a column called `created-at` and to a schema copied from a system that allowed dashes, and the durable fix is an underscore. ### syntax error at or near "union" `select 1 order by 1 union select 2;` stops at `union`. Without parentheses an `ORDER BY` applies to the result of the whole set operation, so once the grammar has read one it treats the statement as complete and cannot accept another arm. Wrap each arm that needs its own ordering, `(select 1 order by 1) union (select 2);`, or move a single `ORDER BY` to the end where it sorts the union's output. ### syntax error at or near "if" `if true then select 1; end if;` fails at column 1. `IF` is a PL/pgSQL statement, and the SQL grammar this checker runs never sees PL/pgSQL, so a conditional written at the top level has no meaning to it. Wrapping the block, `do $$ begin if true then perform 1; end if; end $$;`, parses. A related message hides behind a typo. `create table if exists posts (id int);` stops at `exists`, because the grammar knows `if not exists` and nothing shorter. ### syntax error at or near "select" A `select` the grammar was not expecting is nearly always a missing semicolon. `select id from posts select title from posts;` stops at the second `select`, column 22, since the first statement was still open. `insert into posts (id) values select id from staging;` stops at `select` because `values` and a query are alternatives; drop `values` and write `insert into posts (id) select id from staging;`. And `select select from posts;` fails at column 8, since `select` cannot be a column name without quotes. ### syntax error at or near "create" `create table a (id int) create table b (id int);` stops at the second `create`, column 25, and the missing semicolon after the first closing parenthesis is the whole story. When the caret lands on `table` instead, as in `create or replace table posts (id int);`, the clause itself is the problem, since PostgreSQL has `create or replace` for functions and views and not for tables. Drop `or replace`, or use `create table if not exists`. `create table posts (create int);` stops at column 21 for the reserved-word reason above. ### syntax error at or near "insert" Two `insert` statements run together, `insert into posts (id) values (1) insert into posts (id) values (2);`, stop at the second `insert`, column 35, and the missing semicolon is again the cause. A different shape is `insert posts (id) values (1);`, which stops at `posts` rather than at `insert`, because `into` is mandatory and the grammar had nowhere to put a bare name. ### syntax error at or near "returning" `select id from posts returning id;` stops at `returning`, column 22. The clause exists on `insert`, `update`, `delete` and `merge`, and a `select` already returns rows, so there is nothing to attach it to. Order matters as well. `delete from posts returning id where id = 1;` stops at `where`, since `returning` has to be the last clause, and `delete from posts where id = 1 returning id;` parses. ### syntax error at or near "$1" A `$1` parameter stands in for a value and never for a name. `select * from posts limit $1;` and `insert into posts (id) values ($1);` both parse, while `select * from $1;` stops at the placeholder, column 15, because a table name cannot arrive through a parameter. Build the identifier into the query text instead, and if the name comes from outside, run it through `quote_ident` or `format('%I', name)` on the way in. ### syntax error at or near "merge" The 17 grammar accepts `merge`, so on this page the message comes from a `merge` that starts before the previous statement ended. `insert into posts (id) values (1) merge into posts p using staging s on p.id = s.id when matched then delete;` stops at `merge`, column 35, over a missing semicolon. An action the grammar does not offer produces it too, and `merge into posts p using staging s on p.id = s.id when matched then merge;` stops at the final `merge`, since a matched branch may only `update`, `delete` or `do nothing`. If your server prints this message for a `merge` that stands alone and parses cleanly here, the server is older than PostgreSQL 15, where the command first appeared. A `merge` missing every `when` clause fails at the semicolon instead. A message that is not in this list still follows the same rule. Find the token in quotes, then read leftwards for the clause the grammar was in the middle of. ## Frequently asked questions ### Is this the real PostgreSQL parser? It is libpg-query, the parser from the PostgreSQL sources compiled to WebAssembly, so the message it prints is the same one a PostgreSQL 17 server prints for the same input. ### Which PostgreSQL version does it check against? One grammar, PostgreSQL 17.7, and every result prints the version number 170007 under the verdict. Syntax that arrived in 18 is rejected here, and in the other direction a 14 server rejects MERGE while this page accepts it, so whenever the two disagree the server is the one to believe. ### Why does the position not match my editor's column? The checker counts columns from 1 and counts a tab as one character, where many editors show a tab as four or eight columns. On a multi-line input it also prints the absolute character number, which is what a server error calls Position, counted in characters rather than bytes from the start of the whole text, so accented characters before the token do not shift it. ### Does the checker know my tables? It cannot, because it holds no catalog. A misspelled column or a table that was never created passes here and fails on the server with 42703 or 42P01 rather than the 42601 a syntax error carries. The grammar reads the shape of a statement and nothing about the database behind it. ### Can it check a whole migration file? A whole file is fine. Paste it and either the count of statements comes back or the first statement that fails, with its line number, since the parser stops at the first error rather than collecting them all. One blind spot is the body of a PL/pgSQL function, which the SQL grammar carries as a string, so a typo between $$ markers passes here and fails when the function is created. A body written as begin atomic is real SQL and is checked. ### Does my SQL leave the browser? The parser is WebAssembly running inside this tab. Nothing you paste is sent anywhere, and downloading the parser is the only request this page makes. ### How is it different from a regex validator? A regex validator matches whatever patterns its author thought of. Running the grammar instead means dollar-quoted blocks, ON CONFLICT, window functions and MERGE come back clean. --- # PostgreSQL Connection String Builder > Source: https://seedfa.st/tools/postgres-connection-string-builder > Last updated: 2026-09-14 Fill in the host, port, database, user and password, and this postgres connection string builder writes the URI PostgreSQL clients expect, with reserved characters in the password percent-encoded. Paste a string you already have and it travels the other way, into the fields. ## What it does and how to use it Pick the service you connect to and the fields arrive shaped the way that service documents them. Whatever you end up with is rewritten below as a `psql` invocation, a JDBC URL, a SQLAlchemy `create_engine` call, a Prisma or Drizzle `DATABASE_URL`, and the `seedfast connect` command. Backwards works as well. Drop a string into the box at the top, press Fill the fields, and the credentials come back decoded with `sslmode` and `options` split into their own controls. Whether you came looking for a postgres url builder or a postgres connection string generator, what comes out is the libpq URI, the one form every driver in the table further down either reads as it is or can be rewritten from. ## The libpq URI format and the characters that break it libpq documents the URI it accepts as a single line of grammar, and asks for percent-encoding wherever a part of it carries a symbol with special meaning. ```text postgresql://[user[:password]@][host][:port][/dbname][?param=value] ``` Skip the encoding and the failure is rarely subtle. A `#` begins the fragment and a `/` ends the authority, so `postgresql://user:pa#ss@host/db` is rejected by pg-connection-string and by the browser's own URL parser alike. Where the stray character is an `@`, nothing is rejected at all. The parser splits at the last one it finds, reads `postgresql://user:p@sshost/db` as the password `p` on a host called `sshost`, and hands you a complaint about DNS. Everything after the `?` is parameters. `sslmode` sets how much of the TLS handshake the client insists on, and all six values are laid out further down. Session settings ride in `options`, where `-c search_path=app` becomes `?options=-c%20search_path%3Dapp` once the space and the `=` are escaped. ## Connection strings by provider The shapes below are copied from each provider's documentation, with the project or instance part replaced by a placeholder, so a real host you paste into the builder will run a little longer. Neon has its own page, [How to Seed a Neon Database](/blog/seed-neon-database), where its `-pooler` host is explained. Prisma users should read [Prisma Postgres Seed](/blog/prisma-postgres-seed) for the `DATABASE_URL` and direct URL conventions that ORM adds on top of everything here. ### Supabase Supabase hands out three strings for one database. The direct one, `postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres`, reaches the server itself and resolves to an IPv6 address unless the project has the IPv4 add-on. The shared pooler answers on a host such as `aws-0-eu-central-1.pooler.supabase.com` with the user written as `postgres.[PROJECT-REF]`, and the port decides the mode. On 6543 the pooler runs in transaction mode, where consecutive statements can land on different backends and prepared statements are not supported, which is why Prisma's own docs add `pgbouncer=true` to that URL and to no other. Port 5432 on the same host is session mode, one backend held for the life of the connection. Supabase asks for `sslmode=require` on all of them, with `verify-full` and their root certificate as the stricter option. ### Amazon RDS To reach an RDS instance you copy its endpoint, a host of the form `mydb.c6c8mwvfdgv0.us-west-2.rds.amazonaws.com`, from the Connectivity and security tab; the port is 5432 unless you changed it, and the default master user is `postgres`. Since RDS for PostgreSQL 15 the `rds.force_ssl` parameter defaults to 1, so a client that arrives without TLS is turned away with a `pg_hba.conf` error that ends in `SSL off`, and `sslmode=require` is enough to pass. To have the client verify the certificate as well, download `global-bundle.pem` from the AWS trust store and reference it in the string, `postgresql://awsuser:[PASSWORD]@mydb.c6c8mwvfdgv0.us-west-2.rds.amazonaws.com:5432/mypgdb?sslmode=verify-full&sslrootcert=global-bundle.pem`. That bundle carries the root certificates for every commercial region, and AWS asks that only the roots go into your trust store, never the intermediates, because those rotate. ### Heroku Heroku never shows you a host to type. The add-on writes the whole string into the `DATABASE_URL` config var, shaped `postgres://[USER]:[PASSWORD]@[HOST]/[DBNAME]`, and a failover or a credential rotation (`heroku pg:credentials:rotate`) rewrites that var and restarts the dynos, so any copy hard-coded elsewhere quietly stops working. Read the var at runtime and append what the client needs, which on Heroku is `sslmode=require`, since every connection to Heroku Postgres has to be TLS 1.2 or newer. Pasting the var into the builder is a fine way to read the parts, as long as the copy you deploy keeps coming from the config var. ### Azure Database for PostgreSQL A flexible server answers at `[SERVER-NAME].postgres.database.azure.com` on 5432, and the user is the plain administrator login, `postgresql://[USER]:[PASSWORD]@myserver.postgres.database.azure.com:5432/postgres?sslmode=require`. A login written `user@myserver` comes from the Single Server product Microsoft retired in March 2025, and a flexible server does not accept it. TLS is on by default through `require_secure_transport`, and Microsoft's guidance goes past `require`, asking for `verify-ca` or `verify-full` with the DigiCert Global Root G2 and Microsoft RSA Root CA 2017 certificates in your trust store and nothing below them, since the intermediates rotate without notice. The built-in PgBouncer, once enabled, sits on the same host at port 6432. ### Google Cloud SQL Rather than a host, Cloud SQL gives you an instance connection name, `PROJECT:REGION:INSTANCE`. From Cloud Run the instance is mounted as a Unix socket at `/cloudsql/PROJECT:REGION:INSTANCE`, and libpq takes a socket directory through the `host` parameter in the query string, because a path cannot sit in the authority part of a URI, `postgresql://[USER]:[PASSWORD]@/[DBNAME]?host=/cloudsql/PROJECT:REGION:INSTANCE`. Everywhere else you run the Cloud SQL Auth Proxy, `./cloud-sql-proxy --port 5432 PROJECT:REGION:INSTANCE`, and connect to `127.0.0.1:5432` as though the database were local. Google's own example sets `sslmode=disable` on that hop and the connection is still encrypted, because the proxy carries the TLS itself. The builder needs a TCP host, so the proxy form is the one to feed it. ### Railway and Render Both platforms give a database two addresses. Railway's `DATABASE_URL` points at the private network, `postgres.railway.internal:5432` for a service named `postgres`, reachable only from services in the same project and environment, while `DATABASE_PUBLIC_URL` goes through the TCP proxy at a host such as `shuttle.proxy.rlwy.net` on a port Railway assigns, which is the one to paste into a laptop client. Render draws the same line with an internal URL whose host has no region suffix and an external one of the form `dpg-[ID]-a.oregon-postgres.render.com:5432`. External connections to Render are always TLS and a string carrying `sslmode=disable` is refused, so write `sslmode=require`; on the internal address the certificate is self-signed, which rules out `verify-ca` and `verify-full` there. ## The six sslmode values `sslmode` is a client-side setting and the server never sees it. The six values form a ladder, and each rung adds one thing the client insists on before it will talk. - `disable` tries only a plain connection. - `allow` tries a plain connection first and accepts TLS if the server insists on it. - `prefer` tries TLS first and falls back to plain if the server has none. This is what you get when you write nothing. - `require` refuses anything unencrypted and checks nothing about the certificate. One catch from the libpq docs is that when a root CA file exists in the default location, `require` behaves like `verify-ca`, and relying on that is discouraged. - `verify-ca` requires TLS and checks that a CA you trust signed the server certificate. - `verify-full` does everything `verify-ca` does and also checks that the host name you dialled matches the certificate. The libpq docs frame the ladder as two threats. Eavesdropping is stopped by everything from `require` upward, whereas a server impersonating yours is stopped only by `verify-full`, because `verify-ca` accepts any certificate the CA issued, including one for a different host, and the CA's own policy decides what that is worth. ## Percent-encoding a password A PostgreSQL connection URL with special characters in the password is the case this table exists for. The URI grammar gives `@`, `/`, `?` and `#` structural jobs, so a password containing one of them has to be written as a percent escape, the character's byte in hex after a `%`. The builder does this as you type, and these are the ones that come up. | Character | Write | | --- | --- | | `@` | `%40` | | `#` | `%23` | | `/` | `%2F` | | `?` | `%3F` | | `:` | `%3A` | | `%` | `%25` | | space | `%20` | | `&` | `%26` | | `=` | `%3D` | | `+` | `%2B` | `%` itself has to be escaped, otherwise `100%pass` reads as a broken escape. A `+` stays a `+` in libpq even though HTML form decoding would turn it into a space, a difference that surfaces when a string passes through a web framework on its way to the driver. Where a driver takes the keyword form, `host=db.example.com dbname=app password='p@ss#1'`, none of this applies, since there is no URI to confuse and single quotes around the value are enough. ## Which driver, which format The URI the builder writes is libpq's, and most drivers either link libpq or copy its grammar. The ones that do neither have a shape of their own. | Driver | What it accepts | | --- | --- | | psql | The URI or the keyword form in place of the database name, `psql "postgresql://..."` or `psql "host=... dbname=..."`. | | psycopg 2 and 3 | The same two libpq forms as the `dsn` or `conninfo` argument, with keyword arguments overriding what the string says. | | node-postgres | A `connectionString` URI, or a config object with `user`, `password`, `host`, `port` and `database`. | | JDBC | Its own scheme, `jdbc:postgresql://host:port/database`, with `user`, `password` and `sslmode` as query parameters. | | Npgsql | Semicolon-separated keywords, `Host=...;Username=...;Password=...;Database=...;SSL Mode=Require`. | | Go pgx | The URI or the `key=value` DSN, read the way libpq reads them, environment variables included. | | SQLAlchemy | The URI with the driver named in the scheme, `postgresql+psycopg://`, and a password run through `quote_plus` first. | The JDBC and SQLAlchemy tabs above already rewrite the string in those two shapes, and the psql tab wraps it in double quotes so the shell leaves the `?` and the `&` alone. ## Frequently asked questions ### Why does my password with @ break the URL? Because @ is the separator between the credentials and the host, and the URI parser takes the last one it sees, so part of your password turns into a host name that does not resolve. Type the password into the builder's field rather than into the string and it comes out as %40. Or hand the driver the keyword form, where password='p@ss' needs no escaping at all. ### Do I use port 5432 or 6543 on Supabase? 6543 suits short-lived connections from serverless functions, where transaction mode lets many callers share a few backends, at the price of prepared statements. Stay on 5432 when a connection lives for a while or when the client prepares statements, which is what Prisma's pgbouncer=true works around on the other port. The direct host only ever listens on 5432. ### What is the difference between sslmode=require and verify-full? require is the honest choice when the certificate is self-signed or you have no root file on your side, as on Render's internal address and on most local setups. Wherever the provider publishes a root certificate, as RDS and Azure both do, verify-full is worth the extra file, because it is the only value that catches a server pretending to be yours; the certificate goes in as sslrootcert. ### Does postgres:// mean the same as postgresql://? Yes, libpq accepts both scheme names and so does the builder, which means a Heroku-style postgres:// line and a psql-style postgresql:// one fill the same fields. JDBC is the exception, since it wants jdbc:postgresql:// and nothing else. ### Can I paste a JDBC URL? A JDBC URL works, along with a SQLAlchemy URL, a psql command, or the whole DATABASE_URL line out of an env file. The builder strips the wrapper and reads the URI inside it. ### Can I paste a keyword=value string? Not into the box at the top, which reads the URI form only and answers a host=... dbname=... string with a message saying so. Type its parts into the fields by hand and the URI comes out, ready for a client that wants that shape. ### Does my password leave the browser? It does not. The page has no server to send it to, and closing the tab clears what you typed. --- # PostgreSQL Password Hash Generator: SCRAM-SHA-256, MD5, bcrypt > Source: https://seedfa.st/tools/scram-sha-256-generator > Last updated: 2026-09-14 This Postgres password hash generator turns a password into the SCRAM-SHA-256 verifier string `pg_authid` stores, next to the legacy `md5` form and a bcrypt hash for Supabase `auth.users`. Each result arrives with the SQL that installs it, and the hashing runs in your browser. ## What it does and how to use it Type the password and press Generate the hashes, or use Ctrl+Enter. Three results appear at once, because a Postgres role, an older verifier and a Supabase auth row each want a different string for the same secret. The SCRAM-SHA-256 generator draws a fresh sixteen-byte salt on every run, the role name feeds the SQL snippets underneath it, and the md5 scheme folds that name into the digest as well. A SCRAM verifier holds no name at all, so renaming a role costs you the md5 one and nothing else. Switch to Verify to go the other way. Paste a hash together with the password you think produced it, and the prefix decides what happens next. Given a SCRAM verifier, the page pulls the salt and the iteration count out of the string itself and derives both keys again. bcrypt runs its own comparison, and an md5 digest needs the role name filled in, since the scheme hashed the two together in the first place. ## How PostgreSQL stores a password Write `CREATE ROLE app_user LOGIN PASSWORD 'plaintext'` and the server keeps no plaintext. With `password_encryption` at its default of `scram-sha-256`, what lands in `pg_authid.rolpassword` is four fields glued into one string: the algorithm name, the iteration count, a sixteen-byte random salt in base64, then the StoredKey and ServerKey that RFC 5802 derives through PBKDF2-HMAC-SHA-256. PostgreSQL 18.3 writes 4096 iterations, the value of the `scram_iterations` setting. ```sql SELECT rolpassword FROM pg_authid WHERE rolname = 'app_user'; -- SCRAM-SHA-256$4096:ONzM1YR...g==$ph7/h18pel...=:JnuVWMsaGs...= ``` Hand the server a finished verifier in place of a plaintext and it stores that string exactly as given, which is what makes a generated one usable in a provisioning script. A postgres md5 password hash, the literal `md5` followed by the hex digest of the password with the role name appended, is accepted the same way and PostgreSQL 18 still stores it, although `CREATE ROLE` and `ALTER ROLE` now emit a deprecation warning whenever they build one, which the `md5_password_warnings` setting turns off. Neither scheme covers bcrypt, because PostgreSQL never uses bcrypt for role passwords. It arrives through the `pgcrypto` extension, where `crypt()` and `gen_salt('bf', ...)` are what a local Supabase `seed.sql` reaches for when it fills `auth.users.encrypted_password`, so a bcrypt hash for Supabase auth users comes from pgcrypto or from a page like this one and never from `CREATE ROLE`. Called with no cost argument, `gen_salt('bf')` picks 6. ## The bcrypt prefix pgcrypto will not read JavaScript bcrypt implementations label their output `$2b$`, while the version of the algorithm compiled into `pgcrypto` answers to `$2a$` and `$2x$` and stays quiet about anything else. Given a `$2b$` string, `crypt()` falls through to traditional DES, reads `$2` as the salt, and returns a twelve-character value that compares unequal to what you passed in, so the login fails with nothing in the logs to explain it. Running both against PostgreSQL 18.3 settled it. One salt and digest verified under `$2a$` and failed under `$2b$`, so every hash this page produces carries the `$2a$` label, which is the prefix `gen_salt('bf')` writes itself. ## pg_hba.conf: scram-sha-256, md5 and trust `pg_hba.conf` decides how a connection proves who it is, one line per combination of connection type, database, role and client address, and the method at the end of that line is what a verifier from this page has to be good for. `scram-sha-256` runs the SCRAM-SHA-256 exchange from RFC 7677, in which the client answers a challenge with a proof derived from the salted key and the password itself never crosses the wire, so a captured exchange yields nothing to replay. On the server side the manual describes the stored form as cryptographically hashed and thought to be secure, which is why a string lifted out of `pg_authid` is not a password. This is the most secure method PostgreSQL ships, with the one caveat that old client libraries do not speak it. Older, and still on many servers set up before SCRAM existed, `md5` is a challenge-response of PostgreSQL's own design, in which the client answers a random salt from the server with an MD5 of its digest and that salt. That keeps the password off the wire and out of plain text on disk, and the manual is direct about what it leaves open. A stolen hash is as good as the password, MD5 itself is no longer considered secure against a determined attacker, and the method is deprecated and due for removal in a future release. With `trust` the server compares nothing at all. Anyone who can reach the socket or the port may log in as any role the line allows, superusers included, so the manual confines it to single-user workstations and to Unix sockets protected by file permissions, and calls it seldom reasonable for TCP/IP beyond localhost. A verifier from this page is irrelevant on a `trust` line, since the server never asks for one. ## Moving a database from md5 to scram-sha-256 The manual's order is short. Check that every client library in use speaks SCRAM, set `password_encryption = 'scram-sha-256'` in `postgresql.conf`, have every role set a new password, then change the `pg_hba.conf` lines from `md5` to `scram-sha-256`. Two facts are what make that order safe. Nothing converts an existing verifier. `password_encryption` decides only what a plaintext turns into at the moment a password is set, so flipping it changes nothing about the roles you already have, and each keeps its `md5` string until someone runs `ALTER ROLE ... PASSWORD` for it. On PostgreSQL 18.3, a role created with the setting at `md5` stored `md5c543fa64...`, and the same `ALTER ROLE` with a plaintext after the setting went to `scram-sha-256` stored a string beginning `SCRAM-SHA-256$4096:`. A verifier handed over ready-made is stored as given whatever the setting says, which is why the `CREATE ROLE` from this page works before the flip as well as after it. A `pg_hba.conf` line reading `md5` already accepts SCRAM, which is the second fact. In the manual's words, when `md5` is the method on the line but the role's password is stored for SCRAM, SCRAM authentication is chosen automatically, while the reverse, an md5 verifier against a `scram-sha-256` line, is not accepted at all. So the `pg_hba.conf` edit can wait until the last `md5` verifier is gone, and this query says when that is. ```sql SELECT rolname, left(rolpassword, 4) AS scheme FROM pg_authid WHERE rolpassword IS NOT NULL ORDER BY rolname; ``` Rows reading `md58` are still on the old scheme and rows reading `SCRA` have moved. Once the column is uniform, switch the lines, reload, and nothing that connects should notice. ### Set a password without psql \password `\password` exists because `ALTER ROLE app_user PASSWORD 'plaintext'` carries the secret to the server in clear, where the manual warns it may end up in the client's command history or the server log. psql hashes the password on the client and sends the verifier instead. This page does the same job in a browser tab, and the two statements it writes are the shape psql would send, a `CREATE ROLE` carrying the SCRAM verifier and an `ALTER ROLE` carrying the md5 one. ```sql CREATE ROLE app_user LOGIN PASSWORD 'SCRAM-SHA-256$4096:Y99P0pGJwzLDLJG0cOVAMA==$i7XEmGHu1e4Ut961Du4ZPNXEAfRzrVhUYnR92hHbE8g=:KwBkqnC/ZGim7yVXt2CI8orVOHcdigKBNh5gC0OI4eI='; ALTER ROLE app_user PASSWORD 'md58d572a089a73b734c1b85a488c1e0507'; ``` Both ran on PostgreSQL 18.3, and `pg_authid.rolpassword` came back equal to the string that went in, character for character. Paste either into a provisioning script and the plaintext never has to exist outside this tab and a password manager. ## Frequently asked questions ### Does the password leave my browser? The page makes no network request at any point. The SCRAM derivation runs on the Web Crypto API in this tab, and the md5 and bcrypt values are computed by JavaScript beside it. Whatever you type lives in the tab's memory until you close it, since there is no endpoint on our side that receives a password. ### Will the verifier match what psql \password produces? Not character for character, and it does not need to. SCRAM draws a fresh random salt every time, so the same password yields a different verifier on each run. Both of them authenticate the same login, because the server derives its key from the salt stored in the verifier rather than comparing strings. ### Can the password be recovered from the hash? Reversing it is not how any of these schemes come apart. An attacker instead guesses passwords and hashes each guess, which is exactly what the iteration count and the bcrypt cost are there to slow down. A stolen verifier is still worth rotating the password over. ### Which bcrypt cost should I pick? Cost 10 is the default here, and each step up doubles the work of checking one password, which seed data rarely justifies. Note that pgcrypto's own gen_salt('bf') defaults to 6, so a hash made by crypt() without an explicit cost is cheaper than this one. ### Why does the md5 form need a role name? Because the scheme hashes the password and the role name together, then prefixes the literal string md5. Two roles with the same password therefore store different digests, and renaming a role leaves its md5 verifier unusable. PostgreSQL 18 warns that setting one is deprecated. ### Does a SCRAM password work while pg_hba.conf still says md5? It does, because the manual made that the transition path. When the line says md5 and the role's stored password is a SCRAM verifier, the server runs SCRAM authentication instead. The reverse does not hold, since a password stored as md5 can only be used with the md5 and password methods, so the pg_hba.conf change is the last step of a migration rather than the first. ### Should I raise the SCRAM iteration count? The server's own scram_iterations defaults to 4096 and the field here matches it. Raising it slows every guess an attacker makes and every login you make by the same factor, and the manual adds a subtler point, that a role whose verifier carries a different count from the server setting can be told apart from a nonexistent role by watching how connection attempts are answered. Keep the two equal unless you are changing both. --- # UUID v7 and ULID Generator for PostgreSQL > Source: https://seedfa.st/tools/uuid-v7-generator > Last updated: 2026-09-14 This UUID v7 generator makes time-ordered keys for Postgres, reads the timestamp back out of one you paste, and writes the SQL for a column default, with ULID and UUID v4 beside it. There is no Postgres ULID type, so store a ULID as its 26-character string in `char(26)` or `text`, or as the 16 bytes it encodes in a `uuid` column, and generate it in the application or through a function. ## What it does and how to use it Pick a type and a batch size, press Generate, then copy the block. Beside it the same batch sits wrapped in quotes and parentheses, the shape a multi-row `VALUES` list wants, with a copy button of its own. Paste a value into Decode and it reports the version, the variant bits and the creation time where the format carries one, which the section on reading the timestamp goes through, while a v4 comes back with no time at all, because it has none to give. Under SQL sits an `INSERT` built from the batch on screen, next to five table definitions covering the built-in functions and the two extensions. The decoder has no button because the reading follows what you paste, and on the Generate tab Ctrl or Cmd with Enter draws a fresh batch. ## ULID in a Postgres column A ULID is 128 bits shown as 26 characters of Crockford base32, and the choice is between keeping the characters and keeping the bits. `char(26)` or `text` holds the string as it is, and `pg_column_size` on PostgreSQL 18 reports 30 bytes for either against 16 for a `uuid`. Storing the bits means the application decodes the base32 and hands Postgres 32 hex digits, which `uuid` accepts, so the ULID `01ARZ3NDEKTSV4RRFFQ69G5FAV` becomes `01563e3a-b5d3-d676-4c61-efb99302bd5b`; the 26-character string itself is refused with `invalid input syntax for type uuid`. Both forms sort in creation order across milliseconds, because the first ten characters of a ULID and the first 48 bits of the same value are the same timestamp, and on PostgreSQL 18 three values ordered as `uuid` came out in the same order as the strings under both the C collation and the default one. Keep the string form in one case, since a lower-case ULID sorts after every upper-case one, and within a millisecond the order depends on the generator, as the questions below explain. Whichever form holds it, a B-tree sees new keys arriving at its right-hand edge, which is the point of a time-ordered identifier and the reason a random v4 fills an index differently. The `uuid` column spends 16 bytes per entry instead of 30, so the index is smaller and more of it stays in memory, and there is no ULID generator PostgreSQL ships in core to weigh against that, where v7 has one from 18 onward. ### Read the timestamp out of a UUID v7 The decoder writes the creation time both as ISO 8601 and as Unix milliseconds, which is the number to compare against a `created_at` column when you are checking whether a backfill kept its order, and marks the two variant bits that make the value RFC 9562. Inside the database, `uuid_extract_timestamp()` does the same for v1 and v7 and returns null for anything else, so on PostgreSQL 18 it read 2024-11-30 05:32:01.580 UTC out of a pasted v7 and nothing out of a v4. A ULID stored as `uuid` gets null as well, since its version bits are not 7, and `uuid_extract_version()` reports null for it too; the decoder on this page reads the ULID form directly. ### UUID v7 on PostgreSQL 16 and 17 Native UUID v7 Postgres support arrived with 18, so on 16 and 17 there are three routes. Generate the value in the application, which is what this page does, or install the `pg_uuidv7` extension, whose README covers PostgreSQL 13 through 18 and adds `uuid_generate_v7()`, or write a small SQL function that assembles the bytes yourself. From 18, the UUID v7 generator Postgres runs itself is `uuidv7()`, which on PostgreSQL 18.3 returned version 7 values whose `uuid_extract_timestamp()` matched `now()` to the millisecond, takes an optional interval that shifts the timestamp, and sits beside an explicit `uuidv4()`; `uuid_extract_timestamp()` and `uuid_extract_version()` arrived one release earlier, in 17. ## gen_random_uuid vs uuid_generate_v4, and where v7 fits `gen_random_uuid()` has been in core since PostgreSQL 13, whose release note records that UUID generation had until then lived only in the `uuid-ossp` and `pgcrypto` modules. That history is behind the question people keep landing on, where the extension is installed and `uuid_generate_v4()` still fails. `CREATE EXTENSION` loads into the current database only, and an unqualified call is resolved through `search_path`, so a function sitting in an `extensions` schema stays invisible until you qualify it or put that schema on the path. ```sql select uuid_generate_v4(); -- ERROR: function uuid_generate_v4() does not exist select extensions.uuid_generate_v4(); -- resolves show search_path; -- "$user", public ``` What separates v7 from v4 is the leading 48 bits, which hold a Unix millisecond count. Values made one after another therefore sort in that order, and a B-tree primary key index takes them as appends at one edge instead of as writes landing anywhere in the tree. How much that buys depends on the write volume of the table, which is why this page quotes no benchmark and the next section quotes sizes instead. ## UUID against bigint as a primary key A `bigint` primary key costs 8 bytes and, fed by an identity column, arrives in strictly increasing order, so every new entry lands on the rightmost leaf of the B-tree and the pages before it are never touched again. A `uuid` costs 16 bytes, and a v4 lands anywhere, so an insert can dirty any page of the index. On PostgreSQL 18, twenty thousand rows keyed by `uuidv7()` produced a primary key index of 632 KB against 456 KB for an identity `bigint`, and a heap of 1,024 KB against 872 KB. That gap is the price of a key the application can mint before the row exists and merge across databases without renumbering, though a v7 in a URL does tell the reader when the row was made. v7 exists to keep the cost at the width of the column, since values made one after another sort in that order and reach the tree in the same append-only pattern as the bigint, where a v4 spread its writes over every page. ## Frequently asked questions ### What does this page generate? Batches of one, ten or a hundred values in three formats, UUID v4, UUID v7 and ULID. The decoder reads a value you paste and reports its version, its variant bits and, for v7 and ULID, the millisecond it was made. The SQL tab turns the current batch into an INSERT and lists the table definitions that make the server produce such values instead. ### What is the difference between a ULID and a UUID v7? The two share the same 48-bit millisecond timestamp at the front, so they sort the same way across time. A ULID spends the remaining 80 bits on randomness and is written as 26 base32 characters, while a v7 gives six bits to the version and variant markers, keeps 74 for randomness or a counter, and is written in the 36-character hex form a uuid column expects. Postgres can read the version and timestamp out of a v7 with built-in functions, and out of a ULID only through a decoder like the one on this page. ### Can I store a ULID in a uuid column? Yes, once the application decodes the 26 characters into their 16 bytes and passes them as 32 hex digits. The text form is refused by the uuid input parser. Once inside, the value sorts correctly, but uuid_extract_timestamp returns null for it, because the bits where a v7 carries its version hold random ULID data instead. ### How is UUID v7 different from UUID v4? Both are 128 bits, and with both the odds of a collision are not something you plan around. A v4 is random throughout, so consecutive values land in unrelated places in an index. A v7 opens with a 48-bit millisecond timestamp, which keeps values made in the same period next to each other and lets ordering by the key stand in for ordering by creation time. ### Do I need the uuid-ossp extension? Its own documentation says the module is only necessary for requirements beyond what core PostgreSQL offers. gen_random_uuid() has been built in since 13 and the Postgres uuidv7() function since 18, so a new schema rarely reaches for uuid_generate_v4(). When that call fails although the extension is installed, check which database it was created in and whether its schema is on your search_path. ### Do ULIDs sort in creation order? Across milliseconds they do, because the first ten characters are the timestamp. Inside a single millisecond the answer depends on the generator. The reference implementation re-randomises the tail on every call, and in a check on 2026-09-11 a batch of twenty came back out of order in all two hundred runs, so this page uses the monotonic variant, which increments the tail and keeps a copied batch in the order it was made. ### Does what I paste leave the browser? Nothing leaves it. Values are made and read in JavaScript inside the page, and neither what you paste nor what you generate is sent anywhere. The site does count that the tool was used, and that count carries the name of the tool and nothing else. --- # PostgreSQL TRUNCATE All Tables Generator > Source: https://seedfa.st/tools/postgres-truncate-all-tables > Last updated: 2026-09-14 Paste a schema here and this generator writes the statement that empties it. To have Postgres truncate all tables in one go, name every one of them in a single `TRUNCATE customers, orders, order_items RESTART IDENTITY CASCADE;`, or name the parents and let CASCADE follow the foreign keys to the rest. Where cascading is refused, the DELETE order comes out below as well. ## Truncate every table: the statement Every table in one statement is the form that needs nothing else, because a foreign key only blocks a truncation when the table on its other end is missing from the list. ```sql TRUNCATE customers, orders, order_items RESTART IDENTITY CASCADE; ``` Name only the parents and `CASCADE` does the walking. It empties every table holding a key into one you named, then whatever holds a key into those, across schemas as well, so `TRUNCATE customers CASCADE` on PostgreSQL 18 cleared `orders`, `order_items` and an `audit.log` table in another schema that pointed at `customers`. `ON DELETE CASCADE` on the child changes none of this, since that clause governs `DELETE`, and a truncation of the parent alone is refused exactly as before. ```sql TRUNCATE customers CASCADE; ``` With the database in front of you and no DDL to paste, a `DO` block can read the table list out of `pg_tables` and build the statement itself. Change `'public'` to the schema you mean and it truncates all tables in that schema, whatever the last migration added. Run against a three-table schema on PostgreSQL 18 it assembled `TRUNCATE TABLE public.order_items, public.customers, public.orders RESTART IDENTITY CASCADE`, left every count at zero and handed the next insert id 1. ```sql DO $$ DECLARE statement text; BEGIN SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ') || ' RESTART IDENTITY CASCADE' INTO statement FROM pg_tables WHERE schemaname = 'public'; IF statement IS NOT NULL THEN EXECUTE statement; END IF; END $$; ``` ### cannot truncate a table referenced in a foreign key constraint That line is what PostgreSQL 18 answers when the list is incomplete, followed by a `DETAIL` line, `Table "orders" references "customers"`, and a `HINT`, `Truncate table "orders" at the same time, or use TRUNCATE ... CASCADE`. The hint is the whole fix. Add the table it names to the statement, together with any table that in turn references that one, or put `CASCADE` at the end and let the server find them. The refusal exists because `TRUNCATE` never reads the rows it removes, and confirming that no child row would be left pointing at nothing would take exactly the table scan the command is there to avoid. ### TRUNCATE, DELETE or DROP `TRUNCATE` keeps the table and its indexes and discards the contents in one step, where `DELETE` walks the rows, fires their triggers and leaves the space for `VACUUM` to reclaim. The third option removes the table itself rather than its rows. `DROP TABLE customers` is refused while `orders` still carries a key into it, and on PostgreSQL 18 `DROP TABLE customers CASCADE` took the table and that constraint away together but left the two rows in `orders` where they were, pointing at nothing. Reach for `DROP` when migrations rebuild the schema anyway, and for one of the other two when the schema has to survive the reset. ## What it does and how to use it Paste what `pg_dump --schema-only` gave you, or a few `CREATE TABLE` statements written by hand. Tables and the foreign keys between them are what the parser keeps, and the `SET` and `GRANT` lines a dump wraps around them are stepped over. Under the panel sits every table it found, each with the number of keys pointing at it. Untick one and the line beneath the list names the tables `CASCADE` empties anyway, because a truncation reaches whatever holds a key into a table being truncated, and then whatever holds a key into those. Where cascading is not on offer, the `DELETE order` tab writes one `DELETE FROM` per table with the children first, inside a transaction. The third tab is for a reader who has the database in front of them and no schema file, and hands over the `DO` block above. ## RESTART IDENTITY, locks and the DELETE order A Postgres `TRUNCATE` with `RESTART IDENTITY` sends the sequences owned by the truncated columns back to their start value, so the next row inserted is id 1 again, which a PostgreSQL 18 run confirmed for an identity column and a `serial` alike. Write neither word and you get `CONTINUE IDENTITY`, which leaves them where they stand, and a plain `DELETE` changes no sequence at all. When a sequence has drifted away from the rows for some other reason, the article on [the insert that fails right after a successful load](/blog/postgres-sequence-out-of-sync) covers `setval` and the resync. On a busy database the lock is what gets noticed first. `TRUNCATE` takes `ACCESS EXCLUSIVE` on every table it names and holds it until the transaction ends, so readers wait; `DELETE` takes `ROW EXCLUSIVE` and they carry on. The trade the other way is that a truncation frees the space at once, where deleted rows sit until `VACUUM` reaches them, and row triggers fire for the delete and never for the truncation. To truncate tables in order, Postgres asks for nothing, since one statement empties them all at once. Order matters on the `DELETE` route alone, where the children go before the parents and the whole list runs inside one transaction, which is what the second tab writes. ## Frequently asked questions ### How do I make Postgres truncate all tables in a schema? Run the DO block above with the schema name in its WHERE clause, or paste the schema's DDL into the generator and copy the statement it writes. Both produce one TRUNCATE naming every table, so nothing is left for a foreign key to object to. Tables in other schemas are only touched when CASCADE reaches them through a key. ### Why is TRUNCATE blocked by a foreign key? Because TRUNCATE removes a table's contents without reading them, it has no way to confirm that no row in a referencing table would be left pointing at a parent that is gone. The manual says checking that would take the table scan the command exists to avoid, so PostgreSQL refuses unless the referencing table is in the same statement or CASCADE is given. A self-referencing table is fine on its own, since the referencing table is already in the list. ### Does TRUNCATE reset the sequences? Only with RESTART IDENTITY, and only the sequences owned by the truncated columns, which covers serial and identity columns. Without it the sequences carry on from where they were, so the first row after the reset gets the next unused number rather than 1. ### Does Postgres truncate all tables when I write CASCADE? It truncates the tables you named and every table that references them through a foreign key, directly or through another table, in any schema. Tables with no key into that set are left alone, so CASCADE on one parent is only a full reset when everything hangs off it. The line under the table list in the generator names what a partial selection would take with it. ### Does RESTART IDENTITY roll back? Rolling the transaction back puts the sequence back with the rows, and on PostgreSQL 18 the next insert after such a rollback took the id it would have taken had none of it happened. That holds for RESTART IDENTITY only, since a sequence moved by hand inside the same transaction does not come back with it. ### Why would I want the DELETE order instead? A weaker lock keeps readers going while it runs, and the row triggers a truncation skips do fire on a delete. It is also the way in where the role is not allowed to truncate at all. The cost is speed and disk, since deleted rows sit there until VACUUM reaches them. ### What happens to triggers, and what about session_replication_role? Row triggers do not fire on TRUNCATE, which has statement-level triggers of its own. Setting session_replication_role to replica switches off the constraint triggers behind foreign keys, so a DELETE runs in any order, and it needs the privilege to set that parameter. On PostgreSQL 18 it left child rows pointing at parents that were already gone, so the whole closure has to go in one transaction. ### Does what I paste leave the browser? It does not. The DDL is parsed by JavaScript in this tab and the statement is assembled there as well, so there is nothing to upload and no server to upload it to. The one thing recorded is that the tool ran, under its own name, with none of your text attached. --- # PostgreSQL Connection Pool Size Calculator > Source: https://seedfa.st/tools/postgres-connection-pool-calculator > Last updated: 2026-09-14 Give this Postgres connection pool size calculator a server and the shape of the application in front of it, and four numbers come back. It works out `max_connections`, the pool each worker process should keep, `default_pool_size` for PgBouncer and the ceiling on clients, with presets for what Supabase, Neon and AWS publish. ## What it does and how to use it Start with the server. The self-hosted tab asks for the core count, the memory and the number of spinning disks under the data directory, while a provider tab replaces the first two with a compute size and adds the connection ceiling that provider publishes for it. The other half of the form is what connects. An app instance is a container or a machine, a worker is a process or a thread inside one, and since every worker keeps a pool of its own, a box running four Gunicorn workers arrives at the database looking like four clients. Used as a postgres max_connections calculator, the self-hosted tab works the ceiling out from that layout upwards, the connections the workers will open plus the superuser reserve plus five slots for psql and migrations. On a provider tab the ceiling is fixed, so the same arithmetic runs the other way and the panel says when the layout wants more slots than are left after the reserve. The three blocks underneath follow what you type, so the `postgresql.conf` lines, the pooler settings and the driver call never drift apart from each other, and none of it leaves the tab. ## How many connections a PostgreSQL server should really take For years the project wiki has carried the same answer, `((core_count * 2) + effective_spindle_count)` active connections, with the spindle term falling to zero once the working set is cached. Four cores on an SSD come out at eight, which looks absurd beside the 100 that `max_connections` ships with, until you notice the two figures answer different questions. Eight is how many backends should be doing work at any instant. Attached and idle is a different state, and a backend sitting in it still holds a process and whatever `work_mem` its last sort claimed, which is what the ceiling is really rationing. Past the point where the cores are saturated, one more connection buys a context switch and a share of the lock contention rather than throughput, and that is the argument for a pooler sizing two numbers instead of one: `pgbouncer default_pool_size` is what the formula produced, `max_client_conn` is what your fleet can open. Transaction pooling makes that ratio possible and also catches people out. The server connection goes back to the pool at `COMMIT`, so everything living on the session leaves with it, a `SET`, a prepared statement, a temporary table, a `LISTEN`. Supabase draws the line by port, 5432 reaching Postgres directly or Supavisor in session mode and 6543 reaching the transaction pooler, and Neon draws it by hostname with `-pooler` in the pooled one. Both splits are worked through where they bite hardest, in [seeding a Supabase project](/blog/supabase-db-seed) and in [seeding a Neon database](/blog/seed-neon-database), since a seed run is the long transaction with prepared statements in it that transaction pooling suits least. ### FATAL: sorry, too many clients already This is the line a client gets when every slot under `max_connections` is taken, and for most teams it is how they first learn the fleet has outgrown the server. PostgreSQL raises it as SQLSTATE 53300, `too_many_connections`, and the same code sits behind the message an ordinary role meets a little earlier, `remaining connection slots are reserved for roles with the SUPERUSER attribute`. On a default install that earlier refusal lands on the 98th ordinary connection, since `max_connections` ships at 100 and `superuser_reserved_connections` at three, with `reserved_connections` at zero; the blunter message arrives once the reserved three are gone as well. Both are refused at connect time, so no query ran and nothing has to be rolled back. The process that asked simply has nowhere to sit. The calculator works in the direction that prevents it. Pool per worker is chosen so that instances times workers times pool lands under the slots left once the reserve is taken out, and the panel says in so many words when a layout wants more than that. On the provider tabs the ceiling is the provider's own figure, which is why a Supabase Small at 90 direct connections cannot carry ten containers of four workers holding three connections each without a pooler in between, and why `max_client_conn` rather than `max_connections` is the number to hold against your fleet. ## Provider ceilings the presets carry Every figure below is what the provider tab loads, read from the provider's own documentation on 11 September 2026 and linked under the panel. None of it is inferred, apart from one Neon number named in its paragraph. ### Supabase pool size by compute size Supabase publishes a direct connection ceiling and a pooler client ceiling per compute size, and the presets carry both. - Micro (2 shared cores, 1 GB): 60 direct connections, 200 pooler clients - Small (2 GB): 90 and 400 - Medium (4 GB): 120 and 600 - Large (2 dedicated cores, 8 GB): 160 and 800 - XL (4 cores, 16 GB): 240 and 1,000 - 2XL (8 cores, 32 GB): 380 and 1,500 - 4XL (16 cores, 64 GB): 480 and 3,000 - 8XL (32 cores, 128 GB): 490 and 6,000 - 12XL (48 cores, 192 GB): 500 and 9,000 - 16XL (64 cores, 256 GB): 500 and 12,000 Nano is missing because Supabase lists its CPU as shared without a number, so a Nano owner types what they have into the self-hosted tab. The Supabase pool size the calculator writes is a share of the direct ceiling rather than all of it, 80% while the Data API is quiet and 40% once PostgREST is busy holding connections of its own, and it lives in a dashboard field, Database settings then Connection pooling, rather than in a file. Supabase also notes that the direct column is a recommendation you can change, which is why the page treats it as a ceiling to plan against. ### Neon connection limits by compute unit Neon sets `max_connections` per compute size and runs PgBouncer in front of it, with `default_pool_size` at nine tenths of that figure and `max_client_conn` at 10,000 on every size. Seven connections on each size are held for the Neon superuser, so the number you can use is the published one minus seven. - 0.25 CU (1 GB): 104 - 0.5 CU (2 GB): 209 - 1 CU (4 GB): 419 - 2 CU (8 GB): 839 - 3 CU (12 GB): 1,258 - 4 CU (16 GB): 1,678 - 6 CU (24 GB): 2,517 - 8 CU (32 GB): 3,357 RAM is the only hardware figure Neon documents per compute unit; it says CPU scales with RAM without naming a vCPU count, so the calculator starts from one core per compute unit, never fewer than one, and leaves the field editable. That core count is the one number on this page nobody can cite. The pooled hostname is the one with `-pooler` in it, and a query that finds no free server connection behind it waits two minutes before failing. ### RDS and Aurora max_connections formula AWS publishes a formula rather than a table, and it is the same one for RDS for PostgreSQL and for Aurora PostgreSQL, `LEAST({DBInstanceClassMemory/9531392}, 5000)`. The presets apply it to the memory each instance class advertises, which makes every figure an upper bound. `DBInstanceClassMemory` is what remains after the operating system and the RDS management processes take theirs, AWS says as much on its quotas page without publishing the remainder per class, so the real default sits somewhat under each number here. Aurora adds that whatever you set should leave at least three connections spare for its own automation. - db.t3.micro (2 vCPU, 1 GiB): at most 112 - db.t3.small (2 vCPU, 2 GiB): 225 - db.t3.medium (2 vCPU, 4 GiB): 450 - db.t3.large and db.m6g.large (2 vCPU, 8 GiB): 901 - db.m6g.xlarge (4 vCPU, 16 GiB): 1,802 - db.r6g.large (2 vCPU, 16 GiB): 1,802 - db.r6g.xlarge (4 vCPU, 32 GiB): 3,604 The memory-optimised classes show what the formula rewards. A db.r6g.large has the same two vCPUs as a db.t3.large and twice its connection ceiling, because the divisor only ever sees memory while the wiki formula behind the pool size only ever sees cores. On these tabs the calculator takes the AWS figure as the ceiling and the core count for the pool, so the two numbers pull in different directions on purpose. ## Frequently asked questions ### Why not simply raise max_connections? A connection is a backend process with memory of its own, and once the cores are busy the extra processes queue rather than work. Raising the ceiling moves that queue out of your pooler and into the database, where holding it costs more. ### PgBouncer or Supavisor? On a Supabase project the choice is already made. Supavisor sits in front of the database with nothing to install, and its pool size is a dashboard field. PgBouncer is the usual answer everywhere else, and the two settings this page writes mean the same thing in both. ### Is the pool size per instance or across the whole fleet? It is per process, because a pool belongs to whichever process created it. Ten containers of four workers hold forty pools rather than one, so the figure labelled Pool size is what a single one of those forty gets. ### What changes when the app is serverless? The instance count stops being something you choose, since a function can run in hundreds of copies during a spike and none a second later. Neon lists exactly that as a common way to exhaust a compute size, so enter the concurrency ceiling your platform allows and point the functions at the pooled hostname. ### Do the numbers I type leave the browser? Every one of them stays in the tab. The arithmetic is a few dozen lines of JavaScript and the preset table is a static file, both already loaded with the page, so there is nothing to send and no connection string to ask you for. ### Is transaction pooling safe for my ORM? The queries themselves are safe. Trouble starts with anything that assumes the session is yours, since a prepared statement, a SET, a temporary table and a LISTEN all live on the server connection, which goes back to the pool at COMMIT and may belong to someone else a moment later. The driver block on this page carries the two switches that matter most, pgbouncer=true on a Prisma URL and prepare_threshold=None for psycopg, and an ORM that relies on named prepared statements without a switch like that should talk to port 5432 or the unpooled host instead. ### How many connections does one worker need? One worker needs as many as it can keep busy at once, which for a synchronous worker is one and for an async worker or a thread pool is the number of queries it runs concurrently. The calculator gives every worker an equal share of the formula's answer, so four instances of four workers on an eight-core server get one each. When even one per worker adds up to more than the formula wants busy, the panel says that the rest will queue, which is the case a pooler exists for. --- # PostgreSQL Escape String Tool: Single Quotes, E-Strings > Source: https://seedfa.st/tools/postgres-escape-string > Last updated: 2026-09-14 For Postgres, escape single quote characters by writing each one twice, so `O'Brien` goes in as `'O''Brien'`. This tool does that doubling the way `quote_literal()` does and writes the same value beside it as an E-string, a dollar-quoted block, a LIKE pattern and a quoted identifier, all inside this tab. ## What it does and how to use it The pane on the left opens with a value carrying the characters that normally cause the trouble, an apostrophe, a per cent sign, an underscore, a backslash, a line break and three characters outside ASCII. Type over it and the five panes beside it keep up, each with a copy button of its own, so choosing a form is a matter of reading them rather than switching a mode and losing the last one. Three settings sit above the panes. The first decides whether characters above ASCII travel as `\uXXXX` inside the E-string or stay as themselves. The dollar tag box takes a tag of your own, and where that tag would close the block early the pane falls back to one that fits and says so underneath. The third picks the character that neutralises `%` and `_` for LIKE, where `!` reads better than the default backslash. Unescape runs the other way, so paste any of the forms back, a LIKE pattern with its `ESCAPE` clause included, and the pane shows the text a client would have sent. ## Which PostgreSQL escape string form to use In application code, none of the five is the first thing to reach for, because a parameterised query hands the value to the driver and the driver sends it outside the SQL text, where no quoting mistake can reach the statement. Quoting by hand earns its place in a seed file, a migration, or a script you are writing out as literal SQL, and there the form depends on what the value carries. ### quote_literal and the doubled single quote A string literal ends at its closing quote, so a value carrying a quote of its own has to spell it out, and SQL spells it by writing the quote twice. `O'Brien` goes in as `'O''Brien'`, two characters on the page and one in the column, and `quote_literal('O''Brien')` returns exactly that. Hand it a value with a backslash and it switches form, so `quote_literal('a\b')` comes back as `E'a\\b'`, an E-string with the backslash doubled, which means the same thing whichever way the session is set. `format('%L', value)` produces the identical string for text, and for a null it writes the bare word `NULL`, as `quote_nullable` does, where `quote_literal` returns null itself. ### E-strings and standard_conforming_strings The backslash is where most of the advice online has gone stale. With `standard_conforming_strings` on, the default since PostgreSQL 9.1, a backslash inside `'...'` is nothing but a backslash, and `length('a\nb')` comes back 4. Prefix the literal with `E` and the old rules return, so `length(E'a\nb')` is 3 and `E'\u00e9'` comes out as é. Inside an E-string a quote can be written either way, since `E'It\'s'` and `E'It''s'` both read It's, and a lone `E'\''` is a single quote character and nothing more. ### Dollar quoting and choosing a tag Between `$$` and `$$` nothing is an escape at all, so a function body full of apostrophes, backslashes and per cent signs goes in exactly as written, line breaks included. The one thing to watch is whether your delimiter turns up inside the text, because `$$a$$b$$` ends the block at the second `$$` and fails with a syntax error, while `$q$a$$b$q$` returns `a$$b` untouched. A tag follows the rules of an unquoted identifier without the dollar sign, so it starts with a letter or an underscore, and `$1a$` is read as a parameter instead. The pane here picks a tag that does not occur in the value and says so underneath. ### LIKE patterns and the ESCAPE clause `%` matches any run of characters and `_` exactly one, so a value that contains either has to be escaped before it can be searched for literally. The default escape is the backslash, and `'100%' LIKE '100\%'` is true where `'100x' LIKE '100\%'` is false. Where the pattern already carries backslashes, `ESCAPE '!'` names another character, as in `'100%' LIKE '100!%' ESCAPE '!'`, and `ESCAPE ''` switches escaping off altogether, after which `%` and `_` are wildcards and nothing else. Two escape characters in a row match one literal one, and `LIKE` is case sensitive where `ILIKE` is not. ### quote_ident for identifiers A table or column name is an identifier rather than a string, so it takes double quotes and `quote_ident` instead of `quote_literal`, and a double quote inside it is doubled the same way, `quote_ident('a"b')` giving `"a""b"`. Names that need no quoting come back bare, `quote_ident('orders')` is `orders`, while a capital letter or a space gets the quotes, and so does a reserved word such as `select`. Unquoted names fold to lower case, which is why `CREATE TABLE Orders` leaves you with a table called `orders`, and `SELECT * FROM "Orders"` then fails with `relation "Orders" does not exist`, since the quoted name is a different table that can exist beside it. `format('%I', name)` is the same function under another spelling. ## Frequently asked questions ### How do I escape a single quote in a Postgres INSERT? Write it twice inside the literal, INSERT INTO people (name) VALUES ('O''Brien'), and the row holds one quote. Where the values come from data rather than from your keyboard, let the driver send them as parameters, or build the statement with format and %L so every value is quoted for you. ### What does E'\'' mean? It is an escape string holding one single quote. The E prefix turns backslash escapes on, so \' stands for a quote and \n for a newline, while the two outer quotes are the delimiters as usual. In a plain literal the same character is written as two quotes, and that form works inside an E-string too. ### When should I use dollar quoting instead of escaping? When the value is long, carries quotes and backslashes of its own, or is code, which is why function bodies are almost always written between $$ delimiters. Nothing inside is interpreted, so the text goes in as written and comes out the same. Pick a tag such as $body$ when the text itself might contain $$. ### How do I put a value into a seed file safely? Generate the file with quote_literal or format's %L rather than by concatenating text, and a null value comes out as the bare word NULL through quote_nullable or %L, where quote_literal would return null. For a handful of literals typed by hand, the doubled quote reads best. Either way the file runs the same under psql. ### Is this the same escaping my driver does? The result matches for a text value, though the mechanism is different. A driver normally sends the value as a parameter, outside the SQL text, where no quoting bug can reach the statement. Reach for a parameter wherever your code allows one. ### Does what I paste leave the browser? No. The quoting is a handful of string passes in this tab, with no request going out and nothing kept. That matters more here than on most of these pages, since the value someone reaches for an escaper with is usually a real one. --- # PostgreSQL Error Code Lookup > Source: https://seedfa.st/tools/postgres-error-code-lookup > Last updated: 2026-09-14 Paste a SQLSTATE code, a condition name or the whole error line your driver printed, and this Postgres error code lookup answers with the condition behind it, the class it sits in and what usually produces it. Matching happens in the browser, against a table generated from the PostgreSQL 18 source, and the most common codes are explained one by one further down the page. ## What it does and how to use it The field takes whatever you have in front of you. A bare `23505` resolves on its own, and so does the condition name `unique_violation`. Paste a line out of a log and the code is pulled out of the noise around it; paste one with no code in it at all and the wording is matched instead, which is how `duplicate key value violates unique constraint` lands on 23505 without a number being typed. Every driver carries the code under a name of its own. On node-postgres it arrives as `err.code`; psycopg 2 spells it `pgcode` and psycopg 3 renamed the attribute to `sqlstate`. Java reads it back from `getSQLState()` on the `SQLException` that was thrown. When you are sitting in `psql` and the code is not on screen, `\errverbose` reprints the last error at full verbosity with the SQLSTATE in it. Under the panel sits the whole Postgres error codes list, class by class, and under that the written cards. A link ending `#23503` opens the page on the foreign key card with the field above already filled in, and a code that has no card lands on its row in the list instead. ## The Postgres error codes you meet while loading data Four integrity constraints account for most of what stops a data load. `23505` is the unique violation a rerun of a script comes back with, and it also turns up once manual inserts have left an identity sequence behind the table, which [the insert that fails right after a successful load](/blog/postgres-sequence-out-of-sync) goes through. `23503` is the foreign key violation, and its DETAIL line separates a key missing from the parent table from a parent row a child still points at, which is [referential integrity](/blog/referential-integrity) doing its job. `23502` prints the whole failing row, so the null is visible in position, and `23514` names the check constraint that turned the row down. The other two arrive from somewhere other than your own rows. A deadlock, `40P01`, means two transactions were each waiting on what the other held, and PostgreSQL rolled one of them back to break the cycle. `55P03` is a lock that was not free when a statement asked for it without waiting, which a loader meets when it empties a table something else is still reading. ## SQLSTATE codes explained These are the codes that turn up in application logs most, explained one by one. Every heading carries the code and its condition name, the line under it is the message with the identifiers taken out, and the anchor on each heading is the code itself. ### 08006 connection_failure `server closed the connection unexpectedly` A connection that was working ended in the middle of something. ``` server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend. ``` Also the client's own, for the same reason as 08001: by the time the connection dropped there was nothing on the other end to report a code. The three line version is libpq's, which psql and psycopg print; the last line is pgjdbc's. **Where it comes from** - An idle connection closed by a pooler, a load balancer or a NAT gateway while the pool still believed in it. - A backend that was terminated, by pg_terminate_backend, by the OOM killer or by a failover. - A restart or a maintenance window on a managed instance. **What to check** Start with the server log, which says whether the backend exited or was killed, and those point at different things. A pool that tests a connection before handing it out turns most of the rest into a retry nobody notices. ### 08001 sqlclient_unable_to_establish_sqlconnection `the connection attempt failed` The connection was never established. ``` org.postgresql.util.PSQLException: The connection attempt failed. org.postgresql.util.PSQLException: Connection to db.internal:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections. ``` Written by the driver, not by a server. Nothing that refused or never answered a connection can send a SQLSTATE back through it, so class 08 codes are the client library's own; the two lines above are pgjdbc's. **Where it comes from** - Nothing listening on that host and port. - listen_addresses left at localhost on a server being reached from somewhere else. - A firewall, a security group or a managed instance that has gone to sleep in between. **What to check** Get to the port first, with psql or a plain TCP check from the same machine the application runs on. Until something answers there, the connection string is not the part worth editing. ### 08P01 protocol_violation `bind message supplies 2 parameters, but prepared statement requires 1` The client and the server disagree about the shape of the conversation, most often about how many parameters a statement takes. ``` ERROR: bind message supplies 2 parameters, but prepared statement "" requires 1 ``` **Where it comes from** - A parameter array with more or fewer values than the statement has placeholders, which node-postgres and pgx send straight through as a bind. - Placeholders written as ? in the MySQL style, which PostgreSQL reads as nothing at all, so the statement requires 0 and the client still sends its values. - Placeholders numbered by hand with a gap, $1 and $3 and no $2, so the count the server derives is not the count the client sent. **What to check** Count the placeholders in the statement against the length of the array the driver was given, remembering that numbering starts at $1. The empty statement name in the message means the query went unnamed, which is the normal case, so nothing is wrong with the driver's caching. ### 0A000 feature_not_supported `cannot truncate a table referenced in a foreign key constraint` The statement is valid SQL and PostgreSQL will not do it. ``` ERROR: cannot truncate a table referenced in a foreign key constraint DETAIL: Table "orders" references "users". HINT: Truncate table "orders" at the same time, or use TRUNCATE ... CASCADE. ERROR: FOR UPDATE is not allowed with aggregate functions ``` **Where it comes from** - TRUNCATE on a table something else references, without CASCADE and without naming the other tables. - A locking clause somewhere the server refuses one, such as over an aggregate. - An operation a data type or an extension does not implement. **What to check** This is one of the few codes where the message is the whole answer, and the hint usually spells out the way round. Truncating a group of related tables works when every one of them is named in the same statement. For a whole schema, [the truncate all tables generator](/tools/postgres-truncate-all-tables) writes that statement from your DDL with every table named. ### 21000 cardinality_violation `more than one row returned by a subquery used as an expression` A subquery that had to produce a single value produced several. ``` ERROR: more than one row returned by a subquery used as an expression ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time HINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values. ``` **Where it comes from** - A scalar subquery in a SELECT list or a WHERE comparison whose filter turned out not to be unique. - col = (SELECT ...) where IN (SELECT ...) was meant. - ON CONFLICT DO UPDATE or MERGE meeting the same target row twice in one statement, which the same code reports as the second message. **What to check** Run the subquery on its own with the same parameters and count what comes back. Where several rows are legitimate, LIMIT 1 under an ORDER BY picks one on purpose, and IN or = ANY accepts all of them. **While loading data** A VALUES list with the same natural key twice trips the second form, and the fix is in the file rather than in the statement. ### 22021 character_not_in_repertoire `invalid byte sequence for encoding "UTF8"` Bytes arrived inside a string that are not valid in the database's encoding, and 0x00 is the one it is nearly always about. ``` ERROR: invalid byte sequence for encoding "UTF8": 0x00 ``` **Where it comes from** - A zero byte inside a string, which JSON and most languages allow and no PostgreSQL text column will store. - Text read from a file in Latin-1 or Windows-1252 and sent as if it were UTF-8, which the message reports as some byte other than 0x00. - Binary data, an image or a hash, headed for a text column instead of bytea. **What to check** The byte in the message is the clue. 0x00 means a zero byte to remove in the application before the insert, because the server refuses to build one even through chr(0); anything else means the encoding of the source, and iconv or the driver's client_encoding is where to look. **While loading data** Fixture files exported from another system carry this in, since a nul inside one field is invisible in every editor and fatal in the load. ### 22008 datetime_field_overflow `date/time field value out of range` The parts parsed, and one of them is not a real date or time. ``` ERROR: date/time field value out of range: "2026-13-45" HINT: Perhaps you need a different "DateStyle" setting. ERROR: timestamp out of range: "294277-01-01" ``` **Where it comes from** - Month 13, day 31 of a 30 day month, or a 29 February outside a leap year. - DateStyle reading 03/04/2026 as April 3 on one server and March 4 on another. - A timestamp past what the type can hold, which for timestamp runs out in the year 294276. **What to check** The DateStyle hint is the one to act on when a string loads on one server and fails on another with the same data, because that is a setting difference rather than a data problem. ### 22012 division_by_zero `division by zero` A division, a modulo or an average met a zero denominator. ``` ERROR: division by zero ``` **Where it comes from** - A rate computed over a group whose denominator turned out to be zero. - A NULLIF meant to guard the denominator that is guarding the other operand. - An integer division where the divisor is a count that happens to be empty. **What to check** NULLIF(denominator, 0) turns the failure into a null, which is nearly always what the query wanted from an empty group in the first place. ### 22007 invalid_datetime_format `invalid input syntax for type date` A date or time string does not match the format it is being read with. ``` ERROR: invalid input syntax for type date: "abc" ERROR: invalid value "abc" for "YYYY" DETAIL: Value must be an integer. ``` **Where it comes from** - to_date or to_timestamp called with a pattern that does not describe the input. - Day and month the other way round, so 11/09/2026 parses under one DateStyle and not another. - A month name in a locale the database is not running in. **What to check** Put the string and the pattern side by side, character by character. A pattern with more fields than the string has can still succeed, which is how a wrong value gets in without raising anything at all. ### 22003 numeric_value_out_of_range `numeric field overflow` A number does not fit the column's declared precision, or the type's own range. ``` ERROR: numeric field overflow DETAIL: A field with precision 6, scale 2 must round to an absolute value less than 10^4. ``` **Where it comes from** - numeric(p,s) where the whole part needs more digits than p minus s leaves for it. - An integer column meeting a value past 2147483647. - A sum or a product that overflows even though every input fitted. **What to check** The DETAIL line does the arithmetic for you and prints the largest absolute value the column will take, so the question is only whether the data is wrong or the column was declared too small. ### 22001 string_data_right_truncation `value too long for type character varying` A string is longer than the declared width of the column. ``` ERROR: value too long for type character varying(3) ``` **Where it comes from** - A varchar(n) sized for what the data used to look like. - Trailing whitespace nobody trimmed on the way in. - A generated value, a slug or a concatenation, that grew past the limit as its inputs grew. **What to check** SELECT max(length(col)) over the source data gives the width actually needed. Where there is no real limit to enforce, text has none to declare. ### 22P02 invalid_text_representation `invalid input syntax for type` A string was read as a type it cannot be read as. ``` ERROR: invalid input syntax for type integer: "abc" ERROR: invalid input syntax for type uuid: "not-a-uuid" ``` **Where it comes from** - An empty string where a number, a date or a uuid was expected. - Thousands separators, a currency symbol or a stray space inside a numeric string. - A placeholder such as N/A or a literal dash sitting in a column that is otherwise numeric. **What to check** The offending value is printed in quotes, which makes whitespace and empty strings visible. NULLIF(col, '') is the usual repair when a CSV loader has turned blanks into empty strings. ### 23502 not_null_violation `null value in column violates not-null constraint` A column declared NOT NULL was handed a null. ``` ERROR: null value in column "email" of relation "users" violates not-null constraint DETAIL: Failing row contains (3, null, 20). ``` **Where it comes from** - The insert leaves the column out and the table has no default to fall back on. - A lookup or a join found nothing, so what arrived was null rather than a value. - An empty string in a CSV was read as a null, or a null was read as an empty string. **What to check** The DETAIL line prints the entire failing row, so the null is visible in position. Line that up against the column list of the statement and the missing one is usually obvious. **While loading data** Columns added after a load script was written are the common cause here, since the script cannot know about a new NOT NULL column that has no default. ### 23503 foreign_key_violation `insert or update on table violates foreign key constraint` A referencing column points at a parent row that is not there, or a parent row was about to go while children still point at it. ``` ERROR: insert or update on table "orders" violates foreign key constraint "orders_user_id_fkey" DETAIL: Key (user_id)=(999) is not present in table "users". ERROR: update or delete on table "users" violates foreign key constraint "orders_user_id_fkey" on table "orders" DETAIL: Key (id)=(1) is still referenced from table "orders". ``` **Where it comes from** - The parent row has not been written yet when the child arrives. - The key came from the wrong column, so the value is real but belongs to some other table. - A DELETE or a TRUNCATE is trying to take a parent out while its children remain. **What to check** Read the DETAIL line: it says either that a key is not present in the parent or that it is still referenced from the child, and those are two different bugs. A constraint declared DEFERRABLE INITIALLY DEFERRED moves the check to commit time, which is the one way the schema itself can relax the ordering. **While loading data** A child row cannot exist before its parent does, and that is the whole of what the constraint enforces, so any load that reaches a child table first ends here. ### 23505 unique_violation `duplicate key value violates unique constraint` A row you tried to write carries a value a unique index already holds. ``` ERROR: duplicate key value violates unique constraint "users_email_key" DETAIL: Key (email)=(ada@example.com) already exists. ``` **Where it comes from** - The same row is written twice, usually because a script reran after a partial failure. - The sequence behind an id column sits below the ids already in the table, so the next generated value collides with one of them. - Two sources disagree about what makes a row distinct, and trimming or case folding turns what looked like two rows into one key. **What to check** The DETAIL line names both the column and the value. Query the table for that value before changing anything, because the answer decides whether the incoming row is a duplicate or the constraint is stricter than the data can be. **While loading data** Loading the same table twice without emptying it first returns this code for every row with a unique column, which is why it is the failure people meet most often. When the failure follows an import, a restore or a run of inserts with explicit ids, the sequence behind the column has fallen behind the table, and [the insert that fails right after a successful load](/blog/postgres-sequence-out-of-sync) walks through that case. ### 23514 check_violation `new row for relation violates check constraint` A value broke a CHECK constraint the table declares. ``` ERROR: new row for relation "users" violates check constraint "users_age_check" DETAIL: Failing row contains (4, grace@example.com, -1). ``` **Where it comes from** - A number falls outside the range the constraint allows. - A status or type column received a string the constraint does not list. - Two columns are checked against each other and the pair is inconsistent, an end date before a start date being the classic. **What to check** The constraint is named in the message, and \d+ tablename in psql prints the expression behind that name. The DETAIL line prints the row that broke it. **While loading data** Generated values trip check constraints more often than production values do, because a rule written for humans, a price above zero or a status from a short list, is invisible to anything that did not read the DDL. ### 23P01 exclusion_violation `conflicting key value violates exclusion constraint` Two rows overlap in a way an EXCLUDE constraint forbids. ``` ERROR: conflicting key value violates exclusion constraint "bookings_during_excl" DETAIL: Key (during)=(["2026-09-11 10:00:00","2026-09-11 12:00:00")) conflicts with existing key (during)=(["2026-09-11 09:00:00","2026-09-11 11:00:00")). ``` **Where it comes from** - Two bookings, shifts or price windows cover the same period. - A range was built with inclusive bounds at both ends, so rows that merely touch are read as overlapping. - The constraint includes a column the data does not vary, so rows that should be independent end up compared against each other. **What to check** Both ranges are printed in full in the DETAIL line. Compare the bracket characters as carefully as the timestamps, since [ and ) are what decide whether two ranges meeting at one instant overlap. ### 25006 read_only_sql_transaction `cannot execute INSERT in a read-only transaction` A write was sent through a session, or a transaction, that is not allowed to write. ``` ERROR: cannot execute INSERT in a read-only transaction ``` **Where it comes from** - The connection string points at a read replica or a standby, which accepts every SELECT and refuses every write. - default_transaction_read_only set on the role or the database, often as a safety measure somebody forgot about. - BEGIN READ ONLY, or SET TRANSACTION READ ONLY, earlier in the same transaction. - A managed instance switched to read only by its provider, which some do when the disk fills. **What to check** Two queries answer it from inside the session. SHOW default_transaction_read_only says whether the setting is on, and SELECT pg_is_in_recovery() says whether you are talking to a standby; the statement itself is fine and belongs on the primary. ### 25P02 in_failed_sql_transaction `current transaction is aborted, commands ignored until end of transaction block` An earlier statement in this transaction failed, and everything after it is refused until the block ends. ``` ERROR: current transaction is aborted, commands ignored until end of transaction block ``` **Where it comes from** - A client that caught the first error and carried on sending statements. - A script wrapped in one long BEGIN, where a single bad row takes the rest of the file with it. - A pool handing back a connection that is still sitting inside a failed transaction. **What to check** The error worth reading is the first one, not this one. Scroll back through the log until you find the statement that actually failed, and if some statements are expected to fail, a SAVEPOINT before each one lets you ROLLBACK TO it and keep the transaction alive. ### 26000 invalid_sql_statement_name `prepared statement does not exist` EXECUTE named a prepared statement, and the backend it reached has never heard of it. ``` ERROR: prepared statement "missing" does not exist ``` **Where it comes from** - A transaction mode pooler routing the EXECUTE to a different backend from the one the PREPARE went to, which is behind most reports of this code. - A connection reset by DISCARD ALL, or by a pooler's server_reset_query, between the prepare and the execute. - A name spelled differently in EXECUTE than in PREPARE. **What to check** Same territory as 42P05, and the same settings put it right. A statement prepared per session cannot survive a pooler that moves the session between backends, so either the pooler runs in session mode for that client or the driver stops naming its statements. ### 28000 invalid_authorization_specification `no pg_hba.conf entry for host` The server has no rule in pg_hba.conf that lets this combination of host, user and database connect. ``` FATAL: no pg_hba.conf entry for host "10.0.0.4", user "app", database "app", no encryption ``` **Where it comes from** - A new application host outside every CIDR in the file. - A rule that requires SSL meeting a client that did not use it, or the reverse. - A role that exists but is not named by any line that matches. **What to check** The message prints all four things the server matched on, which is more than most failures give you. pg_hba_file_rules shows the rules as the server parsed them, and the file takes a reload rather than a restart. ### 28P01 invalid_password `password authentication failed for user` The server took the connection attempt and turned down the password. ``` FATAL: password authentication failed for user "app" ``` **Where it comes from** - The wrong password, or one that has been URL encoded once too often on its way into a connection string. - Characters in the password that the connection string parser reads as delimiters. - A rotated password that one deployment, one cron job or one developer machine still has the old half of. **What to check** Look at the server log rather than the client. The message sent to the client is deliberately vague about which half was wrong, and the log line carries the detail that is missing from it. ### 2BP01 dependent_objects_still_exist `cannot drop table because other objects depend on it` A view, a foreign key or a function is built on the object you are dropping, and the server will not drop it alone. ``` ERROR: cannot drop table users because other objects depend on it DETAIL: constraint orders_user_id_fkey on table orders depends on table users view user_orders depends on table users HINT: Use DROP ... CASCADE to drop the dependent objects too. ``` **Where it comes from** - A view or a materialized view selecting from the table. - A foreign key on another table pointing at it, the dependency people forget most. - A down migration written by hand that drops tables in the order the up migration created them. - A type, a sequence or a function that other objects use, since the code covers those as well. **What to check** Every dependent object is listed in the DETAIL line, and it is worth reading to the end before adding CASCADE, because CASCADE drops all of them and the view you forgot about is the one you wanted to keep. Dropping the dependents by name first gets the same result with no surprises. ### 3D000 invalid_catalog_name `database does not exist` The database named in the connection or the statement is not on this server. ``` ERROR: database "nowhere" does not exist ``` **Where it comes from** - A typo in the connection string, or a DATABASE_URL pointing at the wrong environment. - A database nobody has created yet, which is the normal state of a fresh container on its first run. - Case: a database created as "MyApp" is not myapp. **What to check** psql -l lists what the server actually has. Driver constants disagree about the name of this one: the condition is invalid_catalog_name and the same code is called undefined_database in the server source, so both spellings turn up in the wild. ### 3F000 invalid_schema_name `schema does not exist` The statement names a schema, in a qualified name or through search_path, that this database does not have. ``` ERROR: schema "nowhere" does not exist ERROR: no schema has been selected to create in ``` **Where it comes from** - A migration creating tables in a schema before the CREATE SCHEMA that should have preceded it. - A dump restored into a fresh database that never had the schema the dump was taken from. - search_path set to a schema that does not exist here, which turns every unqualified CREATE into the second message. - Different environments using different schema names, with one of them hard coded. **What to check** \dn lists the schemas that exist, and SHOW search_path says where an unqualified CREATE was going to put things. A role can carry its own search_path through ALTER ROLE ... SET, so the value in the session is not always the one in postgresql.conf. ### 40001 serialization_failure `could not serialize access due to concurrent update` The transaction could not be placed in a serial order with the ones running beside it, so it was rolled back. ``` ERROR: could not serialize access due to concurrent update ERROR: could not serialize access due to read/write dependencies among transactions HINT: The transaction might succeed if retried. ``` **Where it comes from** - REPEATABLE READ or SERIALIZABLE isolation, where another transaction changed a row this one had already read. - Two transactions writing the same row under SERIALIZABLE. - A read and write pattern across three transactions that SERIALIZABLE cannot order, even though no two of them touch the same row. **What to check** The hint is an instruction rather than a suggestion. Anything running at these isolation levels needs a retry loop around the whole transaction, and the server says so in the message it sends. ### 40P01 deadlock_detected `deadlock detected` Two transactions are each holding something the other needs, and PostgreSQL rolled one of them back to break the cycle. ``` ERROR: deadlock detected DETAIL: Process 4521 waits for ShareLock on transaction 8831; blocked by process 4520. HINT: See server log for query details. ``` **Where it comes from** - Two sessions updating the same rows in a different order. - A foreign key check taking a lock on a parent row that another transaction is busy updating. - Batch jobs running side by side over overlapping key ranges. **What to check** The detail line names both processes, and the hint is pointing at the server log, which is where the statements are. Turning on log_lock_waits makes the next one far easier to read. **While loading data** Parallel loaders meet this one: inserting a child row takes a lock on its parent row, and two workers that reach each other's parents in the opposite order leave the server to choose which of them dies. ### 42601 syntax_error `syntax error` The parser gave up. What it names is where it stopped, and the mistake is usually the token just before that. ``` ERROR: syntax error at or near "selct" LINE 1: selct 1 ``` **Where it comes from** - A missing comma in a column list, or one comma too many before a closing parenthesis. - An unquoted identifier that happens to be a reserved word, user and order catching almost everybody at some point. - A string closed with the wrong quote, or a dollar quoted body whose opening and closing tags do not match. **What to check** Read the token before the one the message names. The position psql prints counts characters from the start of the statement rather than from the start of the line, which matters once a statement runs over several lines. Pasting the statement into [the syntax checker](/tools/postgres-syntax-checker) reports the same error and position the server would, with the statement in front of you. ### 42501 insufficient_privilege `permission denied for table` The role you are connected as is not allowed to do that. ``` ERROR: permission denied for table users ``` **Where it comes from** - The object is owned by another role and no grant was ever made. - Row level security is enabled and no policy matches the current role. - The connection string points at a read only or pooled user with fewer rights than the one the query was tested with. **What to check** SELECT current_user says who the server thinks you are, which is not always who you meant to be, and \dp tablename prints the grants that exist on the object. ### 42830 invalid_foreign_key `there is no unique constraint matching given keys for referenced table` A foreign key is being declared against columns the parent table does not guarantee to be unique. ``` ERROR: there is no unique constraint matching given keys for referenced table "users" ``` **Where it comes from** - REFERENCES users (email) where email is indexed but not unique. - A composite key referenced by only some of its columns. - A parent whose key is covered by a partial unique index, which does not count. **What to check** Add the UNIQUE constraint on the parent first, or point the foreign key at the parent's primary key, which is what it usually meant. \d parent shows which constraints exist to be referenced. ### 42804 datatype_mismatch `column is of type bigint but expression is of type text` The value's type does not match the column's and no implicit cast joins them. ``` ERROR: column "user_id" is of type bigint but expression is of type text HINT: You will need to rewrite or cast the expression. ``` **Where it comes from** - A number arrived quoted, so as far as the parser is concerned it is text. - The driver bound a parameter as a string. - A CASE or a UNION whose branches return types PostgreSQL cannot reconcile. **What to check** An explicit cast makes the statement run. Whether the value should have been cast or the column should have been a different type is the better question, and it is worth asking before the cast becomes permanent. ### 42P18 indeterminate_datatype `could not determine data type of parameter` A parameter is used somewhere that gives the parser no clue what type it should be. ``` ERROR: could not determine data type of parameter $1 ``` **Where it comes from** - A placeholder compared with IS NULL, or standing on its own in a SELECT list, with no column or cast beside it to borrow a type from. - A null passed from the application by a driver that sends values without a type, node-postgres being the usual one. - PREPARE without a type list, on a statement where the placeholder's type cannot be inferred. **What to check** Cast the placeholder in the SQL itself, $1::int or $1::text, and the parser has what it needs. Drivers that attach types to the values they send avoid this for anything they can see, which is why the same statement can fail from one client and run from another. ### 42703 undefined_column `column does not exist` The name does not resolve to a column of anything in scope. ``` ERROR: column "emai" does not exist HINT: Perhaps you meant to reference the column "users.email". ``` **Where it comes from** - A typo, which the HINT line will often catch for you. - A column a migration renamed or dropped, in a query that stayed as it was. - Case: a column created as "userId" is not userid, and only the quoted spelling finds it. **What to check** Take the hint seriously, since PostgreSQL only offers one when it found a near match. After that \d tablename settles what the table really holds. ### 42883 undefined_function `function does not exist` No function of that name accepts those argument types. ``` ERROR: function lenght(unknown) does not exist HINT: No function matches the given name and argument types. You might need to add explicit type casts. ``` **Where it comes from** - A typo in the name. - Arguments of the wrong type, which is what the hint about casts means: the name exists, that signature does not. - A function belonging to an extension that is not installed in this database. **What to check** \df name* lists the signatures that do exist. An unknown in the message is a bare string literal whose type the parser never settled, and an explicit cast on that literal is often the whole fix. ### 42P01 undefined_table `relation does not exist` No table, view, sequence or index of that name is visible from the current search_path. ``` ERROR: relation "userz" does not exist ``` **Where it comes from** - The object lives in a schema that is not on search_path. - The migration that creates it has not run against this database. - Case again: a table created as "Users" is a different relation from users. **What to check** SHOW search_path first, then \dt *.* to find which schema the name actually lives in. A pooler or a framework that sets search_path per session is worth ruling out early. ### 42704 undefined_object `type does not exist` A type, a role, an extension, a constraint or a collation is named that this database does not have. ``` ERROR: type "nope" does not exist ERROR: role "nobody_here" does not exist ERROR: constraint "nope" of relation "t" does not exist ``` **Where it comes from** - An extension type such as citext, vector or hstore used before CREATE EXTENSION ran in this database. - A custom enum or domain living in a schema that is not on search_path. - A role named in a GRANT or in a connection string that was created on one server and never on this one. - A constraint named in DROP CONSTRAINT that was renamed, or that only ever existed under a generated name. **What to check** The word before the quoted name says what kind of object was looked up. For a type, \dT *.* shows what exists and where; for an extension type, SELECT extname FROM pg_extension says whether the extension is installed in this particular database, because extensions are installed per database rather than per server. ### 42P05 duplicate_prepared_statement `prepared statement already exists` A prepared statement is being created under a name this backend already has one for. ``` ERROR: prepared statement "q" already exists ``` **Where it comes from** - A pooler in transaction mode, PgBouncer or Supavisor, handing the session's next statement to a backend where a statement of that name was already prepared by somebody else. - Prisma, pgjdbc or Npgsql naming statements by counter, s0 and S_1, over connections a pooler multiplexes. - PREPARE run twice in one session with the same name, which is the plain case. **What to check** The fix lives in the driver's URL rather than in the SQL. Through a transaction mode pooler, switch server side prepared statements off, pgbouncer=true for Prisma or prepareThreshold=0 for pgjdbc, or run the migration step over a direct connection. ### 42P07 duplicate_table `relation already exists` Something with that name is already there and CREATE will not replace it. ``` ERROR: relation "users" already exists ``` **Where it comes from** - A migration ran twice. - Two migrations create the same object in the same schema under different filenames. - An object left behind by a run that failed halfway and was never cleaned up. **What to check** CREATE TABLE IF NOT EXISTS silences it, which is the right answer in a bootstrap script and the wrong one in a migration, where the duplicate is telling you the recorded history and the database no longer agree. ### 42710 duplicate_object `type already exists` The name is taken already, by a type, a role, a constraint or an extension, and CREATE refuses to reuse it. ``` ERROR: type "mood" already exists ERROR: role "r" already exists ERROR: check constraint "c1" already exists ``` **Where it comes from** - A migration that creates an enum, rerun after a partial failure or applied to a database it had already been applied to. - Two constraints in one table given the same name by hand. - A role created by hand on the server that a provisioning script later tries to create again. **What to check** CREATE TYPE has no IF NOT EXISTS, so the usual guard for an enum is a DO block that catches duplicate_object and carries on. For a constraint, \d tablename shows the one already holding the name. ### 42702 ambiguous_column `column reference is ambiguous` A column name in the query matches a column in more than one of the tables it joins, and PostgreSQL will not guess which one you meant. ``` ERROR: column reference "id" is ambiguous ``` **Where it comes from** - A join between two tables that both have an id, a name or a created_at, with the column written bare in the SELECT list or the WHERE clause. - A query that used to read one table and had a join added later, so names that were unique are not any more. - A CTE or a subquery whose output column shares a name with a column of the table it is joined to. **What to check** Qualify the name with the table or its alias, u.id rather than id. The message quotes the bare name, and searching the statement for it finds the one place it appears without a prefix. ### 42P10 invalid_column_reference `there is no unique or exclusion constraint matching the ON CONFLICT specification` ON CONFLICT names columns, and no unique index or constraint covers exactly those columns, so the server has nothing to detect the conflict with. ``` ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification ``` **Where it comes from** - ON CONFLICT (email) on a table where email has an ordinary index rather than a unique one. - A unique constraint over two columns while ON CONFLICT names only one of them. - A partial unique index, whose WHERE clause has to be repeated after the column list before the planner will use it. **What to check** \d tablename lists the unique indexes with their column lists, and the ON CONFLICT target has to match one of them column for column. ON CONFLICT ON CONSTRAINT constraint_name is the other spelling, where naming the constraint replaces matching its columns. **While loading data** Upsert loaders meet this one on the first row, because the natural key the script conflicts on, an email or a slug, was never declared unique in the DDL. ### 42P16 invalid_table_definition `multiple primary keys for table are not allowed` The CREATE TABLE or ALTER TABLE describes something a table cannot be. ``` ERROR: multiple primary keys for table "dup" are not allowed ERROR: column "a" is in a primary key ``` **Where it comes from** - PRIMARY KEY written on a column and again as a table constraint, or an ALTER TABLE adding one to a table that already has one. - A migration generated from a model in which two fields were both marked as the key. - DROP NOT NULL on a column that belongs to the primary key, which is the second message. **What to check** \d tablename in psql shows the constraint that already exists. A second unique key is a UNIQUE constraint rather than a second PRIMARY KEY, and a key column stays NOT NULL for as long as it is part of the key. ### 53200 out_of_memory `out of memory` The server asked for memory, from the operating system or from its own shared segment, and did not get it. ``` ERROR: out of memory DETAIL: Failed on request of size 1073741824 in memory context "ExecutorState". ERROR: out of shared memory HINT: You might need to increase "max_locks_per_transaction". ``` **Where it comes from** - A sort, a hash join or an aggregate that outgrew work_mem and then outgrew the machine, often a query that was fine until the table got large. - Too many backends each allowed a generous work_mem, so the sum is what the host cannot supply. - One transaction touching thousands of relations, partitions or savepoints and filling the lock table, which is what the shared memory form means. **What to check** The DETAIL line names the memory context, and the server log around it prints the whole context tree at the moment of failure. For the shared memory form, count rows in pg_locks and raise max_locks_per_transaction, a setting that only takes effect after a restart. ### 53300 too_many_connections `sorry, too many clients already` Every connection slot on the server is taken. ``` FATAL: sorry, too many clients already ``` **Where it comes from** - More application instances, workers and cron jobs than max_connections was ever set for. - A pool that opens a connection per request and forgets to give it back. - Sessions parked in idle in transaction, holding a slot and a snapshot. **What to check** Count what is connected with SELECT count(*) FROM pg_stat_activity and compare that against SHOW max_connections. Raising the setting buys time; a pooler in front of the server is what fixes the arithmetic. [The connection pool size calculator](/tools/postgres-connection-pool-calculator) turns RAM, cores and the number of application instances into a max_connections figure and the PgBouncer lines to go with it. ### 54000 program_limit_exceeded `index row size exceeds btree version 4 maximum` A value is larger than a hard limit inside the server, most often the size of one index entry. ``` ERROR: index row size 3216 exceeds btree version 4 maximum 2704 for index "notes_body_idx" DETAIL: Index row references tuple (0,1) in relation "notes". HINT: Values larger than 1/3 of a buffer page cannot be indexed. Consider a function index of an MD5 hash of the value, or use full text indexing. ERROR: null character not permitted ``` **Where it comes from** - A plain B-tree index on a text column that now holds a document, a long URL or a JSON blob. - A unique constraint on a column meant to be short that received one long value. - A string containing a zero byte, which no text type in PostgreSQL will store. **What to check** The hint has both answers for the index case, an index on md5(col) when what you need is equality and a full text index when what you need is search. For the zero byte, strip it before the insert, since nothing on the server side will take it. ### 55006 object_in_use `database is being accessed by other users` Something else still holds the object the statement wants to take away, and the server will not pull it out from under them. ``` ERROR: database "app" is being accessed by other users DETAIL: There is 1 other session using the database. ERROR: cannot drop the currently open database ``` **Where it comes from** - DROP DATABASE while an application pool, a psql window or a GUI still has a session open on it. - The session issuing the drop is connected to that very database, which is the second message. - CREATE DATABASE using it as the template while other sessions are connected to it. **What to check** pg_stat_activity filtered by datname lists the sessions in the way. DROP DATABASE ... WITH (FORCE) ends them for you, and for the template case the only fix is to wait for those sessions to close, or to end them by hand. ### 55P03 lock_not_available `canceling statement due to lock timeout` A lock the statement needed was held elsewhere, and the statement chose not to wait. ``` ERROR: canceling statement due to lock timeout ERROR: could not obtain lock on relation "users" ``` **Where it comes from** - NOWAIT on LOCK TABLE or on SELECT ... FOR UPDATE. - lock_timeout set, and reached. - A migration asking for ACCESS EXCLUSIVE while a long running query still holds the table. **What to check** pg_locks joined to pg_stat_activity names the session holding what you want. Setting a short lock_timeout before DDL is the usual defence, since a migration that fails fast is better than one that queues and blocks everything arriving behind it. **While loading data** Emptying a table before writing to it needs ACCESS EXCLUSIVE, so anything still reading that table holds the load up, and with lock_timeout set the truncate comes back as 55P03 instead of waiting. ### 57014 query_canceled `canceling statement due to statement timeout` Something cancelled the statement rather than the statement failing on its own. ``` ERROR: canceling statement due to statement timeout ``` **Where it comes from** - statement_timeout reached, whether it was set on the role, the database or just this session. - A pooler or a proxy enforcing a timeout of its own, below the server's. - A deliberate pg_cancel_backend from somewhere else. **What to check** SHOW statement_timeout inside the session that failed, since the value may have come from the role rather than from anything in your code. The same code covers a user pressing Ctrl-C, so the message text is what tells the two apart. ### 57P01 admin_shutdown `terminating connection due to administrator command` The backend serving this connection was told to stop, by a person, a tool or the server shutting down. ``` FATAL: terminating connection due to administrator command ``` **Where it comes from** - pg_terminate_backend run against the session, by a DBA or by an idle session reaper. - A restart of the server, a failover on a managed instance, or a resize that moves the database to another host. - Maintenance a cloud provider applied at the hour its console announced and nobody read. **What to check** The server log says who did it, since a terminate call is logged under the terminating session and a shutdown announces itself a few lines earlier. The connection is gone either way and the client has to reconnect; a pool that checks connections before handing them out hides the next one. ### P0001 raise_exception `the text your own RAISE EXCEPTION wrote` A function or a trigger written in PL/pgSQL raised an error on purpose, and this is the code it gets when it does not set one. ``` ERROR: quota exceeded for tenant 42 ``` The text is whatever the RAISE statement was given, so no two databases print the same one. The line above is from a DO block raising with a format string. **Where it comes from** - A BEFORE INSERT or UPDATE trigger enforcing a rule the table's constraints cannot express. - A function behind PostgREST or Supabase guarding access, where the exception is the intended way to say no. - A migration or a seed script that asserts something about the data and stops when it is not so. **What to check** Search the schema for the text, since whoever wrote the function wrote the message; SELECT proname FROM pg_proc WHERE prosrc LIKE '%quota exceeded%' finds it. RAISE can carry its own SQLSTATE through the ERRCODE option, and a function that sets one turns up under that code instead of this one. ## Frequently asked questions ### What is SQLSTATE 23505? SQLSTATE 23505 in Postgres is unique_violation, the Postgres unique violation error. The row being written carries a value that a unique index or a primary key already holds, and the DETAIL line names the column and the value. Scripts that run twice raise it, so do two sources that disagree about what makes a row distinct, and so does an id sequence that has fallen behind rows inserted with explicit ids. The 23505 card above has a check for each of those. ### What is the difference between 23503 and 23505? Both sit in class 23, integrity constraint violations, and they point in opposite directions. 23505 says a row duplicates a value that has to be unique within its own table, while 23503 says a row refers to a parent that another table does not have, or that a parent is being removed while children still point at it. Read the DETAIL line, which for 23505 says a key already exists and for 23503 says a key is not present or is still referenced. ### Is 40001 a bug in my code? More often it is a missing retry than a wrong statement. 40001 means the transaction ran at REPEATABLE READ or SERIALIZABLE and could not be ordered against a concurrent one, so the server rolled it back and said in the hint that a retry might succeed. Code that uses those isolation levels needs a loop that reruns the whole transaction on this code, and the bug to look for is a handler that catches it and carries on as if the write had happened. ### How do I read the SQLSTATE in node-postgres or psycopg? Catch the error the driver throws and read the five character string off it rather than parsing the message. node-postgres puts it on err.code, with err.detail, err.constraint and err.table beside it; psycopg 3 uses exc.sqlstate and psycopg 2 exc.pgcode, and both expose the constraint name through exc.diag.constraint_name. Compare it as a string, since codes such as 42P01 are not numbers, and branch on the two character class when every integrity violation should be handled the same way. ### What is the difference between an error class and an error code? The class is the first two characters, the code is all five. Class 23 is integrity constraint violations and 23505 is the one about a unique index, so reading the class first already narrows the problem down. ### Can I map PostgreSQL error codes to HTTP status codes? A few of them map cleanly enough to be worth doing, 23505 as a 409 and 23503 or 23514 as a 422 being the common choices in APIs. Most of the rest do not map at all, and a blanket rule turns a bug in your own SQL into a 400 that blames the caller. ### Does what I paste leave the browser? No. The code table ships with the page and the matching runs in this tab, so a pasted log line is never uploaded. What gets recorded is that a lookup ran and which of the 262 codes it landed on. ### Which PostgreSQL version are these codes from? PostgreSQL 18, generated from errcodes.txt in the server source, the file Appendix A of the manual is built from. Codes get added between versions and almost never change meaning. --- # PostgreSQL Enum Migration Generator > Source: https://seedfa.st/tools/postgres-enum-migration-generator > Last updated: 2026-09-14 This postgres enum migration generator writes the SQL to add, rename or remove a value of a PostgreSQL enum type, in your browser. The Postgres alter enum add value form is one `ALTER TYPE`, and so is a rename; removing one means a rebuild, which the script writes for you. ## What it does and how to use it Paste the declaration into the pane on the left, or a whole dump with it buried somewhere inside; the first `CREATE TYPE ... AS ENUM` in the text becomes the list below. Type over a value to rename it, press the cross to take one out, add one with the row at the bottom. The arrows move a value, which is how a new label lands in the middle of the ordering rather than on the end. Under the list go the columns that store the type, one per line, written the way a column is declared, like `orders.status NOT NULL DEFAULT 'pending'`. When you do not know what those are, the Check tab holds a catalog query that writes the lines for you. Migration is the script to run. Read Rollback before you do, since it is the only place this page says what your change will not undo. ## Add a value to a Postgres enum `ALTER TYPE order_status ADD VALUE 'refunded'` is the whole of it, and the label goes on the end of the ordering. With `IF NOT EXISTS` in front of the label, a value already present produces a notice instead of an error, which is what a migration that may run twice wants; without it the server answers `enum label "refunded" already exists`. The generator writes the guard while the IF NOT EXISTS option is selected and leaves it out under Plain. Postgres alter enum add value has a second form for the middle of the list, `ADD VALUE 'on_hold' BEFORE 'paid'` or `AFTER 'shipped'`, and the generator reaches for it on its own. Move the new row with the arrows and the statement comes out anchored to whichever neighbour you left it beside. On PostgreSQL 18.3, a type declared as `pending, paid, shipped` read `pending, on_hold, paid, shipped, cancelled` after one `BEFORE 'paid'` and one `AFTER 'shipped'`. The manual notes that comparisons involving a value placed anywhere but the end can be slightly slower than comparisons among the original members, which is the reason the generator writes the clause only when the position calls for it. ```sql ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'on_hold' BEFORE 'paid'; ``` None of this touches the tables, since the statement takes a lock on the type's own catalog entry and none on the tables that store it; on 18.3, `pg_locks` showed no lock at all on a table using the type while the `ADD VALUE` was open, and no row is rewritten, so an add on a table of a hundred million rows finishes in the time a catalog update takes. ### unsafe use of new value `ERROR: unsafe use of new value "refunded" of enum type order_status`, SQLSTATE 55P04, with the hint that new enum values must be committed before they can be used, is what a migration gets when it adds a value and uses it inside the same transaction block. PostgreSQL 12 and later accept `ADD VALUE` between `BEGIN` and `COMMIT`, but the label is not usable until that `COMMIT` returns, so an `INSERT` or `UPDATE` carrying it on the next line fails and the whole block rolls back, new label included. PostgreSQL 11 and older refuse `ADD VALUE` inside a block outright. Migration runners that wrap each file in a transaction meet this constantly. The fix is two migrations, the `ADD VALUE` in one and the data change in the next, or Transaction set to None on this page so that each statement commits on its own, and the comment at the top of the generated script says which of the two situations it was written for. ## Rename a value Of the three edits, renaming is the one that costs nothing. An enum value is a row in `pg_enum` that every column using the type points at, and `ALTER TYPE order_status RENAME VALUE 'pending' TO 'queued'` changes the text of that row while the pointers stay put, so every row that held `pending` reads `queued` afterwards without having been written. A column default declared on the old label follows it, which PostgreSQL 18.3 confirmed by reporting `'queued'::order_status` as the default straight after the rename and inserting `queued` on the next `DEFAULT VALUES`. The position in the ordering does not move. The Postgres rename enum value statement has been in the grammar since PostgreSQL 10, so a migration for anything older needs the rebuild described below. Two errors are worth recognising on sight. `enum label "paid" already exists` means the new name is taken, and `"nope" is not an existing enum label` means the old one was mistyped. The generator emits one `RENAME VALUE` per edited row, and the rollback it writes is the same statements the other way round. ## Remove a value, and why there is no DROP VALUE There is no Postgres remove enum value statement. Ask for one and the server says `dropping an enum value is not implemented`, SQLSTATE 0A000, and the manual states the rule behind it, that existing values cannot be removed and their order cannot change short of dropping and re-creating the type. What the generator writes for a removal is that re-creation, in the order the server needs it. ```sql BEGIN; ALTER TYPE ticket_status RENAME TO ticket_status_old; CREATE TYPE ticket_status AS ENUM ('open', 'closed'); UPDATE tickets SET status = 'open' WHERE status = 'pending'; ALTER TABLE tickets ALTER COLUMN status DROP DEFAULT; ALTER TABLE tickets ALTER COLUMN status TYPE ticket_status USING status::text::ticket_status; ALTER TABLE tickets ALTER COLUMN status SET DEFAULT 'open'; DROP TYPE ticket_status_old; COMMIT; ``` Any renames come first, while the old type still owns every row, because the `USING` cast further down matches labels by text. Rows holding the removed label are moved to the replacement you chose beside it, or set to `NULL` when you left it blank, and the page warns when a `NOT NULL` column makes the blank impossible. The default is dropped before the type change and set again after it, since a stored default still points at the old type and is what turns the column change into `default for column "status" cannot be cast automatically to type ticket_status`. Reordering surviving values, which no `ALTER TYPE` can do, produces this same script. The expensive line is `ALTER COLUMN ... TYPE`. It takes an `ACCESS EXCLUSIVE` lock on the table and rewrites it together with its indexes, and the lock is held until `COMMIT`, so on a large table the transaction wants a maintenance window. Read out of `pg_locks` on 18.3 just before the `COMMIT` above, the table showed `AccessExclusiveLock` granted to the session for the whole tail of the script. A rollback brings the type back and leaves the rows as the forward script left them, because that script rewrote every row holding the removed label and nothing in the table records which rows those were; the rollback the generator writes therefore restores `pending` to the type, leaves those rows at `open`, and says so in its first comment. Copy the column somewhere first if those rows matter. If the labels change every quarter, what you have is a lookup table wearing an enum, and a small statuses table with a foreign key into it loses a row to `DELETE` instead. You pay a join for that, plus one more table your seed data has to fill, which is one more thing that [drifts away from the schema](/blog/seed-file-maintenance). ## Frequently asked questions ### Can I add an enum value inside a transaction? From PostgreSQL 12 on, yes, as long as nothing in the same transaction uses the new label, so a block that adds refunded and then updates rows to it fails on the second statement with 'unsafe use of new value'. Split it into two migrations, or set Transaction to None here so each statement commits by itself. PostgreSQL 11 and older refuse the statement inside a block. ### How do I add a value in the middle of the order? Move it with the arrows before you copy the script. The statement then carries BEFORE or AFTER and the neighbour you left it beside, which is the only way to place a label anywhere but the end, since the order of existing values cannot be changed afterwards. The manual adds that comparisons on a value placed this way can be a little slower than on the original members. ### What lock does the type swap take? The swap takes ACCESS EXCLUSIVE on every table whose column moves, through ALTER COLUMN ... TYPE, and holds it until COMMIT while the table and its indexes are rewritten underneath. Reads and writes to that table wait for the whole script. ADD VALUE and RENAME VALUE lock the type's catalog entry and leave the tables alone. ### Can I change the order of existing enum values? There is no ALTER TYPE for that. Reordering is the one postgres change enum values request the grammar leaves out, so the generator treats a reorder of surviving labels like a removal and rebuilds the type in the new order, moving every column across. BEFORE and AFTER on ADD VALUE position a new label only. ### Why does the rollback not bring my rows back? The forward script rewrote them, setting every row that held the removed label to the replacement, and from then on nothing in the table tells them apart from rows that held the replacement all along, so the rollback restores the labels and leaves the data where the forward script put it. The script says so in its first comment. ### What about a Prisma or Drizzle enum? Both declare the enum in their own schema file and emit the PostgreSQL DDL from it. Paste that DDL here to see what the database itself needs, then make the matching edit in schema.prisma or your Drizzle schema so the two agree. ### Does my DDL leave the browser? No. The declaration is parsed and the scripts assembled by JavaScript in the tab you are reading, and this page has no endpoint to send them to. The one thing recorded is that the tool ran, with nothing you typed attached.