Seedfast

Seedfast

Exact Row Counts, Ratios and Shares

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.

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.

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.

[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.

=== 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.

    "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, and the mechanics of passing a scope at all are in 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.

=== 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.

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.
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.

=== CONTACTS PER CUSTOMER (how many customers have N contacts) ===
 contacts_per_customer | customers
-----------------------+-----------
                     4 |       150

=== ADDRESSES PER CUSTOMER ===
 addresses_per_customer | customers
------------------------+-----------
                      2 |       150
=== 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.

=== 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.

=== 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.

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.

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.

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 works through, one sentence at a time.