Postgres Test Data Generator

A postgres data generator turns CREATE TABLE statements into rows. Paste your schema, set how many rows each table gets, and this one fills them in your browser, foreign keys and check constraints included, then hands the result over as SQL, CSV or JSON.

Insert an example schema

Load a schema to configure, preview and export.

Nothing loaded. Nothing you paste leaves this tab; Postgres runs inside it. Ctrl or Cmd with Enter loads the schema.

What it does and how to use it

The postgres data generator opens on the Schema tab, the three-table sample already in the editor and nothing running behind it. Replace that text with your own CREATE TABLE statements, or take a different schema from the Examples menu, then press Load schema. That press is what downloads PostgreSQL 18.3 compiled to WebAssembly, starts it in a worker thread and runs your DDL through it. A statement your own server would refuse is refused here too, with the same message and the same character position. Before that first press the page has fetched nothing at all.

Once the DDL has run, the schema is read back out of the catalog rather than out of the text you pasted, so the column types are the ones Postgres settled on, the enum labels are its labels, and the order the tables have to be filled in comes from the foreign keys it recorded. The status line at the top right of the panel carries the outcome, Nothing loaded until the first press and then something on the order of PostgreSQL 18.3 in your tab · 6 tables.

Configure is where the numbers live. Each table has a row count of its own, the Tiny, Demo and Load presets set all of them at once, and selecting a table lists its columns with the generator chosen for each. Change what you want changed, leave the rest, and Generate builds the values and inserts them table by table while the status line counts the rows as they land, Generating 2,300 of 4,300 rows, ending on 4,300 rows in 1.8 s.

The Preview tab reads the first fifty rows of each table back out of the database they went into, worth a look before you commit to a hundred thousand of them. Under Export sit the three formats, each with the options that format has, a copy button for the text ones and a download button for all of them.

How column names and types become data

A column's name and its type choose the generator between them, which is why nothing asks you what a column means. An email column on a table of customers is filled from the name that row just got, so the address and the person match. Timestamps behave the way a reader expects, with created_at somewhere in the last year and updated_at never landing before it. Give a status column a CHECK (status IN ('pending', 'paid', 'shipped')) and those three words are the only ones it draws from, since anything else would be refused on the way in.

Where the name says nothing, the type decides. numeric(10,2) becomes a decimal with two places, varchar(40) gets text that fits inside forty characters, an enum column draws from its own labels, and a bigint that happens to be a foreign key takes its value from the parent rows inserted a moment earlier.

Column name What it gets
email, *_email An address built from the row's own name, unique where the column is
first_name, last_name, full_name Person names, balanced across the table
phone, mobile A number in one consistent format
street, city, state, postal_code, country One address, coherent across the whole row
price, amount, total, balance A decimal inside an editable range, never below a >= 0 check
status, role, plan, tier A weighted pick from the enum labels or the CHECK list
created_at, updated_at, deleted_at Timestamps in the last year, in the order those three names imply
birth_date, dob A date between eighteen and eighty years back
is_active, has_* A boolean, mostly true
sku, code, invoice_number A patterned identifier, unique
password_hash, token, api_key A fixed placeholder that resembles no real secret

Every guess is overridable from the select beside the column name. Generators are grouped there by what they produce rather than by the type they fit, and each one unfolds its own settings under the row. Numbers take a minimum, a maximum and a precision, while dates take a window instead. A Choice generator carries the list of values with a weight beside each, which is how status stops being an even split and starts looking like a table where most orders were paid and a few were cancelled.

Two more settings sit on every column. Null rate is the share of rows left empty, held at zero on a NOT NULL column and worth raising on the nullable ones, since a deleted_at filled in on every row is not a table your code will ever meet. Unique is switched on and locked for primary keys and unique constraints, and offered elsewhere when repeats would bother you.

The seed is the last of them, and it sits in the rail beside the row counts. Every value in the run comes out of it, so the same schema with the same settings and the same seed produces the same rows tomorrow, and an export headed for a fixture a test asserts against depends on that.

Foreign keys, unique constraints and checks

Parents go in before children, and every foreign key, unique constraint and CHECK in the schema is enforced by the Postgres running in the tab rather than approximated by the generator. After the DDL has run, the foreign keys are walked and the tables sorted into an order where every referenced table is already full by the time a row needs to point into it, which is the ordering a hand-written seed file gets wrong the week somebody adds a table in the middle of the graph.

Child rows then draw their parent from the keys actually present, so no exported foreign key points at a row that was never inserted. How they draw is a setting of its own. Uniform spreads the children evenly over the parents, while skewed hands a few parents most of the children and leaves a long tail with one apiece, closer to how orders per customer really distribute.

Some schemas loop, and there the ordering stops being obvious. Two tables referencing each other, or a chain that comes back round on itself, cannot both go first, so what decides the outcome is nullability. Where one of the keys in the loop accepts NULL, the first pass writes NULL into it and a second pass fills it in once the other side exists, and the loop is reported as breakable. With every key in the loop declared NOT NULL there is no order at all, so those tables are marked skipped in the table list with the reason on the row, and the rest of the schema is generated without them.

CHECK constraints are honoured on the way in rather than discovered afterwards. Constraints that list values, CHECK (status IN (...)), become the column's choices, and the numeric ones, CHECK (total >= 0) or CHECK (rating BETWEEN 1 AND 5), bound the range a generator may use. Conditions past that, a check spanning two columns or an expression the reader cannot take apart, are left to the database to enforce, and the database does enforce them.

When a row is refused, what the panel shows is Postgres's own message with the constraint name in it. The table it happened on is marked failed, the tables that finished before it keep their rows, and Preview and Export still work on whatever made it in.

Exporting SQL, CSV and JSON

Export reads the rows back out of the tables they were inserted into, which is why the three formats are three renderings of one dataset rather than three separate generations.

SQL comes out as multi-row INSERT statements with the batch size in your hands. Four switches sit beside it, namely the explicit column list, ON CONFLICT DO NOTHING, a BEGIN and COMMIT wrapper, and the CREATE TABLE statements at the top for a database that does not have the schema yet. Identity columns need a word of their own. Postgres would generate those values itself, but the child rows further down the file already carry the ids this run handed out, so the ids are written explicitly and a column declared GENERATED ALWAYS AS IDENTITY gets OVERRIDING SYSTEM VALUE on its insert, the clause that makes a real server accept a supplied value instead of refusing it.

CSV writes a file per table, quoted to RFC 4180, with a header row you can turn off, a delimiter of comma, semicolon or tab, and a choice of how a NULL is written, empty, the word NULL, or \N for a later COPY. Two tables mean two files, so anything wider than a single table downloads as a zip.

JSON comes out in one of two shapes. The object form is one document keyed by schema.table with an array of rows under each key, the shape a fixture loader usually reads. NDJSON gives a file per table and a row per line, which suits an importer that streams.

Open in playground skips the file altogether. It exports the SQL with the DDL included and opens the browser Postgres playground on it, where the same PostgreSQL 18.3 build runs a select over what you just generated, or an explain analyze if you want to see what the planner does with a table that size.

Demo data for a stand or a screenshot

Screenshots are an honest use for a postgres fake data generator. A dashboard holding three rows photographs badly, and a demo account where every customer has exactly one order reads as generated from across the room. The weights and the distribution setting are there for exactly that. Weight status sixty toward paid, twenty-five toward shipped, ten toward pending and five toward cancelled, set the foreign key distribution to skewed, and the customer list comes back with a few names carrying a dozen orders each and a tail of names carrying one.

There is a wall here, and it is worth meeting before a demo gets built on top of it. Columns are generated one at a time, and although the values inside a single row are made to agree with each other, nothing is summed across tables, so orders.total is a plausible amount rather than the total of that order's line items. A list view, a chart or a screenshot of a dashboard never shows the gap. It opens the moment somebody in the demo clicks into an order and reads the three prices underneath. Past that point the totals have to be derived from the schema rather than drawn column by column.

The demo data generator guide goes further into the difference between data that looks real on a screen and data that survives being clicked into.

What stays in your browser

Your schema and every row made from it stay inside this tab. A test data generator online usually means the schema travels to somebody else's machine, and this one has nowhere to send it. What executes your statements is PGlite, the PostgreSQL source compiled to WebAssembly, fetched once when you press Load schema and then run inside a worker in this tab, and the value library follows on the first Generate. After those two downloads the network sits idle until you reload the page.

No account is asked for, no schema is kept on a server of ours, and nothing you typed reaches our analytics, which record that the tool ran, which format was exported and whether the run finished. None of those records carries a table name, a column name or a value. The rows themselves live in the tab until you close it or load another schema, which drops the database first.

Where a free postgres data generator stops

Every generator that lives in a browser tab has a boundary, and here it is a schema you can paste. Forty tables of DDL load as happily as three, but the file is yours to carry, the row counts are yours to set table by table, and the whole thing is yours to paste again after the next migration, which somewhere past a dozen tables stops being a minute's work.

Realism runs out in a particular place as well. The values here are believable column by column and coherent inside a row, which covers a local database, a screenshot, or a fixture a test reads three fields out of. Data that has to behave like production is a different problem, the one where totals agree with their line items, dates follow a lifecycle and a customer's history reads as a single story. Seedfast reads the live schema of the database it connects to, works out from the table and column names what each table is holding, and writes the rows into those tables directly.

Then there is the pipeline, where no browser runs and nobody is around to press Generate. seedfast seed --scope "50 customers with orders" is one command against a connection string, so the command that fills a laptop is the same one a CI job runs right after its migrations. None of that takes anything away from a tool that turns a pasted schema into rows in a few seconds, and the Postgres test data generator comparison sets the options in this band beside each other.

Example schemas

Everything in the Examples menu is printed here as well, so a search for a sample Postgres schema lands on the DDL itself rather than on a menu behind a click. Each one loads and generates without an edit, none of them needs an extension, and the identifiers in them are placeholders by construction, which is why no card number, diagnosis code or account reference in this section could ever have belonged to anybody.

Sample tables

Customers, orders and order items make the three-table shape most seed scripts start from.

create table customers (
  id         bigint generated always as identity primary key,
  email      text not null unique,
  first_name text not null,
  last_name  text not null,
  country    char(2) not null default 'US',
  created_at timestamptz not null default now()
);

create table orders (
  id          bigint generated always as identity primary key,
  customer_id bigint not null references customers (id),
  status      text not null check (status in ('pending', 'paid', 'shipped', 'cancelled')),
  total       numeric(12,2) not null check (total >= 0),
  placed_at   timestamptz not null default now()
);

create table order_items (
  id         bigint generated always as identity primary key,
  order_id   bigint not null references orders (id) on delete cascade,
  sku        text not null,
  quantity   integer not null check (quantity > 0),
  unit_price numeric(10,2) not null check (unit_price >= 0)
);

SaaS workspace

Organizations, users, memberships, plans, subscriptions and invoices share an enum and a self-reference.

create type plan_tier as enum ('free', 'starter', 'team', 'enterprise');

create table organizations (
  id         uuid primary key default gen_random_uuid(),
  name       text not null,
  slug       text not null unique,
  created_at timestamptz not null default now()
);

create table users (
  id          uuid primary key default gen_random_uuid(),
  email       text not null unique,
  full_name   text not null,
  avatar_url  text,
  invited_by  uuid references users (id),
  created_at  timestamptz not null default now(),
  last_seen_at timestamptz
);

create table memberships (
  organization_id uuid not null references organizations (id) on delete cascade,
  user_id         uuid not null references users (id) on delete cascade,
  role            text not null default 'member' check (role in ('owner', 'admin', 'member')),
  joined_at       timestamptz not null default now(),
  primary key (organization_id, user_id)
);

create table plans (
  id            serial primary key,
  tier          plan_tier not null unique,
  monthly_price numeric(8,2) not null check (monthly_price >= 0),
  seat_limit    integer not null check (seat_limit > 0)
);

create table subscriptions (
  id              bigint generated always as identity primary key,
  organization_id uuid not null references organizations (id),
  plan_id         integer not null references plans (id),
  started_at      date not null,
  ends_at         date,
  is_active       boolean not null default true,
  check (ends_at is null or ends_at >= started_at)
);

create table invoices (
  id              bigint generated always as identity primary key,
  subscription_id bigint not null references subscriptions (id),
  number          text not null unique,
  amount_due      numeric(10,2) not null check (amount_due >= 0),
  currency        char(3) not null default 'USD',
  issued_at       date not null,
  paid_at         timestamptz
);

E-commerce catalog

Categories nest under each other, and products, customers, carts, orders, line items and reviews hang off them.

create table categories (
  id        serial primary key,
  name      text not null,
  parent_id integer references categories (id)
);

create table products (
  id          bigint generated always as identity primary key,
  category_id integer not null references categories (id),
  sku         text not null unique,
  name        text not null,
  description text,
  price       numeric(10,2) not null check (price >= 0),
  stock       integer not null default 0 check (stock >= 0),
  is_active   boolean not null default true,
  tags        text[] not null default '{}'
);

create table customers (
  id         bigint generated always as identity primary key,
  email      text not null unique,
  first_name text not null,
  last_name  text not null,
  phone      text,
  city       text,
  country    char(2) not null default 'US'
);

create table carts (
  id          uuid primary key default gen_random_uuid(),
  customer_id bigint references customers (id),
  created_at  timestamptz not null default now()
);

create table orders (
  id           bigint generated always as identity primary key,
  customer_id  bigint not null references customers (id),
  status       text not null check (status in ('new', 'paid', 'shipped', 'delivered', 'returned')),
  subtotal     numeric(12,2) not null check (subtotal >= 0),
  shipping_fee numeric(8,2) not null default 0,
  placed_at    timestamptz not null default now()
);

create table order_lines (
  order_id   bigint not null references orders (id) on delete cascade,
  product_id bigint not null references products (id),
  quantity   integer not null check (quantity > 0),
  unit_price numeric(10,2) not null,
  primary key (order_id, product_id)
);

create table reviews (
  id          bigint generated always as identity primary key,
  product_id  bigint not null references products (id) on delete cascade,
  customer_id bigint not null references customers (id),
  rating      smallint not null check (rating between 1 and 5),
  body        text,
  created_at  timestamptz not null default now(),
  unique (product_id, customer_id)
);

Fintech ledger

Accounts, cards, merchants and transactions carry signed amounts and a running status.

create table accounts (
  id             uuid primary key default gen_random_uuid(),
  holder_name    text not null,
  iban           text not null unique,
  currency       char(3) not null default 'EUR',
  balance        numeric(14,2) not null default 0,
  opened_at      date not null,
  closed_at      date,
  check (closed_at is null or closed_at >= opened_at)
);

create table cards (
  id          uuid primary key default gen_random_uuid(),
  account_id  uuid not null references accounts (id) on delete cascade,
  last4       char(4) not null,
  expires_on  date not null,
  is_frozen   boolean not null default false
);

create table merchants (
  id       serial primary key,
  name     text not null,
  category text not null check (category in ('grocery', 'travel', 'software', 'restaurants', 'utilities', 'other')),
  country  char(2) not null
);

create table transactions (
  id           bigint generated always as identity primary key,
  account_id   uuid not null references accounts (id),
  card_id      uuid references cards (id),
  merchant_id  integer references merchants (id),
  amount       numeric(12,2) not null,
  currency     char(3) not null,
  status       text not null default 'settled' check (status in ('pending', 'settled', 'reversed')),
  booked_at    timestamptz not null default now(),
  reference    text not null unique
);

Clinic scheduling

Patients, providers, appointments and encounters come without a single real identifier in sight.

create table providers (
  id        serial primary key,
  full_name text not null,
  specialty text not null check (specialty in ('general', 'cardiology', 'dermatology', 'pediatrics', 'orthopedics')),
  email     text not null unique
);

create table patients (
  id          uuid primary key default gen_random_uuid(),
  mrn         text not null unique,
  first_name  text not null,
  last_name   text not null,
  birth_date  date not null,
  phone       text,
  email       text,
  city        text,
  created_at  timestamptz not null default now()
);

create table appointments (
  id           bigint generated always as identity primary key,
  patient_id   uuid not null references patients (id) on delete cascade,
  provider_id  integer not null references providers (id),
  scheduled_at timestamptz not null,
  duration_min integer not null default 30 check (duration_min between 10 and 180),
  status       text not null default 'booked' check (status in ('booked', 'checked_in', 'completed', 'no_show', 'cancelled')),
  reason       text
);

create table encounters (
  id             bigint generated always as identity primary key,
  appointment_id bigint not null unique references appointments (id) on delete cascade,
  started_at     timestamptz not null,
  ended_at       timestamptz,
  notes          text,
  follow_up_days integer check (follow_up_days is null or follow_up_days > 0),
  check (ended_at is null or ended_at >= started_at)
);
FAQ

Frequently asked questions

This is a free test data generator, and nothing on the page asks you to sign in. There is no key to request and no email step, because the work happens in your own browser rather than on a server somebody has to pay for. The only ceilings are on row counts, and the panel shows those before it lets you press Generate.

Nothing you paste is uploaded, because this site has no endpoint that receives it. Your DDL is executed by a copy of PostgreSQL that the browser downloads once and runs in a worker thread, so the statements never cross the network. What our analytics record is that the tool ran, which format was exported and whether the run finished, with none of the text you typed attached to any of it.

One run is capped at 100,000 rows across every table and 50,000 in any one table, and Generate stays disabled while a plan sits over either number. Both ceilings are there because the database and the rows live in this tab's memory. The Load preset, which puts 10,000 rows in each root table and 30,000 in each child, is the one that reaches them quickly on a wide schema.

A column whose enum type or CHECK constraint lists the allowed values gets a Choice generator holding exactly that list, and both the values and the weight beside each one are editable, so paid can outnumber cancelled six to one. On a column with no such constraint the same generator is one pick away in the select, and it takes whatever lines you give it. Values you add there are used as they stand, which is how a status set nobody else would guess gets into the export.

The seed field in the rail is what repeats a run. Every random value comes out of it, so the same schema with the same settings and the same seed reproduces the run exactly, today or next month. Changing a generator or a row count moves the output, as it should for a fixture you intend to commit.

Generators driven by a form ask you to name every field and hand back one table's worth of values, which leaves the relationships between tables to be reconciled after the export. A postgresql data generator that reads DDL starts from your CREATE TABLE statements instead, so the types, the constraints and the foreign keys are read rather than described. Fake data and dummy data are one output under two names, and a form-driven postgresql dummy data generator is aiming at the same rows this page is. The difference lands in the export, where every child row here already carries the id of a parent row that exists.

Each column is generated on its own, and while the values inside a single row are made to agree, nothing is summed across tables. An order gets a plausible total, its line items get plausible prices, and the two were never computed from one another. When somebody in a demo will open an order and read the numbers under it, those totals have to be derived rather than drawn, a job for a generator that reads the whole schema as one model.

PostgreSQL 18.3, compiled to WebAssembly as PGlite 0.5.8 and started inside this tab. Your DDL goes through the real parser and the real constraint checks, which is why the wording of an error is the server's own. Extensions beyond the handful PGlite bundles are not available, so a schema that opens with CREATE EXTENSION for one of the others needs that line taken out before it loads.