Load testing at real volume
A load test measures the database it hits. Give it production-scale volume, real value skew, and hot tenants.
Your load test is only as good as the table behind it
Teams spend serious effort on the traffic side of a performance test: client counts, think time, ramp profiles, a k6 or Locust script reviewed line by line. Then all of it hits a database that holds 5,000 tidy rows.
Database performance is a function of the data that is in the database. The same query against the same schema and the same indexes will report a completely different number depending on row count, cardinality, and skew. Volume is the variable that costs the most to get wrong, and it is the one a hand-written seed script cannot supply, because nobody hand-writes four million rows.
The industry describes the requirement plainly: realistic test data needs volume, cardinality, skew, and age. A test catalog of 5,000 products cannot validate a marketplace that will search 20 million.
What clean, small data hides
Three specific failures go undetected until production, and each needs a different property of the data to show up:
- The index never gets used. PostgreSQL does not create indexes on foreign key columns automatically. An unindexed FK join costs nothing at 100 rows, so the plan your CI exercises is not the plan production picks. At a million rows the same join costs seconds.
- Selectivity is fiction.
WHERE status = 'cancelled'matches 2% of production orders. Uniform random data makes it match 25%, so the planner correctly chooses a scan and your test reports a number about a query nobody runs. - Hot parents stay invisible. Real workloads are lumpy: a few accounts hold most of the rows, a handful of products carry most of the orders. Uniform generation spreads children evenly across parents and hides the tenant that blows up your
ORDER BYand your buffer cache.
Match the volume
Point Weavori at a schema and ask for the row count you actually need. Memory does not grow with it: rows are produced, batched, and streamed via COPY, so a ten-million-row table costs the same RAM as a thousand-row one.
Size the run before you start it. weavori estimate reports table count, approximate row counts, and an estimated generation time without writing anything, so you know whether the run belongs in a nightly job or a 90-second pull request.
For very large sources, sampling depth is a dial, and the trade-off is accuracy against time:
| Flag | What it does | Use when |
|---|---|---|
| default | Reads pg_stats most-common-values and frequencies | You need plans that match production |
--fast | Rough 1% table sample instead of full analysis | Wide schemas, big sources, quick turnaround |
--sample-rows N | Draws a fixed-size sample for distribution analysis | The source is enormous and you want explicit control |
--no-sampling | Skips source statistics; values come from type and name rules | Volume is all you need, shape is not |
Match the skew
This is the part a row count alone does not fix, and it is where the distribution knobs earn their keep.
Categorical columns concentrate the way production does by default, because the sampler reads the real frequency list. For the cases it cannot infer, declare the shape:
orders.customer_id=zipf is the heavy-tenant switch. On a foreign key column, the child's value is a reference to a parent, so the only meaningful reshape is which parent gets drawn; zipf concentrates children onto a few parents instead of spreading them evenly. That is how you manufacture the account with 500,000 orders that your load test never found.
One limit worth stating before you rely on it. Automatic sampling reads most_common_vals and most_common_freqs, which is what decides selectivity on categorical and enum-backed columns. It does not read histogram_bounds, so a continuous numeric column with no repeated values is generated from its type and name rules, not from production's distribution. For those columns you declare the shape with normal or lognormal, and a weighted model pins exact label probabilities. Run ANALYZE on the target and compare pg_stats side by side if you want to see precisely which columns carried over.
Measured, and reproducible on your hardware
Cookbook recipe 04 builds this scenario end to end: customers, orders, order_items, products, four million rows total, every foreign key intact, ANALYZE run afterward so the planner sees real statistics. One report query, before and after adding the two indexes PostgreSQL did not create for you:
Query before optimization: ~4.8s (Seq Scan on order_items per order)
Query after optimization: ~320ms (Index Only/Index Scans)A 10 to 30 times speedup depending on hardware, and the Buffers counts drop further than the timings do, which is what actually matters under concurrent load. The EXPLAIN (ANALYZE, BUFFERS) output in the repo is the proof; run it on your own machine before quoting the numbers.
For reference, generation throughput on the schema sizes above lands around 100,000 rows across 14 tables in 12.4 seconds, and the engine was validated against a 500 GB stress run. Constant-memory streaming is what makes a multi-million-row benchmark database a one-command job rather than an overnight restore.
What this does not do
- It does not replace a load generator. Weavori fills the database. Driving concurrent traffic at it is k6, Locust, Gatling, or
pgbench, and those tools work better once the database behind them is shaped like production. - Row count is not the same as scale. A one-million-row replica reproduces plan shape and index behavior. It does not reproduce cache pressure, WAL contention, or connection-pool exhaustion at a terabyte. Treat a benchmark on synthetic volume as a directional result, and confirm the big numbers on a dataset sized to production.
- Specific production keys do not carry over. Values are freshly generated. When the test depends on one particular pathological row, that is a rehearsal against real rows, not a generation run.
- The plan half has its own write-up. Why estimates diverge from production, and how PostgreSQL 18's portable statistics compare to generating skewed rows, is covered in Postgres query plans without production data.
Get started
Go deeper
Reproduce the full experiment with cookbook recipe 04. The large-scale generation guide covers sampling strategies, batching, and streaming behavior at millions of rows, and the generate reference documents --rows, --fast, --sample-rows, --distribution, and --seed.