Writing a Scope: Four Runs on One Schema
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.
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 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.
seedfast seed
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.
> 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.
✓ 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.
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;
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 covers the rest of the session, and the runs below pass the scope up front.
What one sentence buys
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.
[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.
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
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,
enterpriseon 50 of 50 rows - 50 addresses, country code
DEand 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.
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
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-0001throughINV-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
normalpriority, 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 has the scopes for that.
Asking for one table
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.
[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
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
DEand 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 covers two heavily numbered scopes over three runs each, with the queries for counts per table, children per parent and named status shares.