Seed a Database with Realistic Test Data
Seed a database with realistic test data — generate a shaped e-commerce dataset of purchases, refunds, and chargebacks as CSV, ready to load into a dev or staging database instead of copying production.
A development database seeded with three hand-typed rows and a staging environment running against an empty table hide the same class of bug: a pagination control never tested past page one, a query that returns instantly against a dozen rows and takes seconds against ten thousand, an edge case that only exists because a real distribution has a long tail. Copying a snapshot of production data fixes the volume and shape problem but trades it for a worse one — real customer names, payment details, and order histories sitting in an environment that was never built to hold them, reachable by anyone with access to dev.
Eventum closes that gap by generating the dataset instead of copying it. Build a generator that produces an e-commerce transaction dataset as a CSV file: purchases, refunds, and chargebacks in a realistic 80/15/5 mix, with correlated fields — customer, product, amount — instead of independently random values. The result is a file shaped like a real transactions export, ready to load into whatever database backs dev or staging today.
Seed dev database with realistic data
Three rows of hand-typed fixture data confirm that a transactions table accepts the right columns and nothing more. They don't confirm that a paginated transaction list still responds quickly on page 40, that a monthly revenue report holds up once chargebacks and refunds are mixed in at their real, rare frequency instead of one of each by hand, or that a report which only ever saw positive amounts survives its first negative refund row. All three failure modes need the volume and the shape a handful of manual rows can't provide — which is exactly what the generator built below produces: ten thousand transactions with a realistic distribution, not ten thousand copies of the same row.
Copying a production snapshot instead would supply that volume, but it drags real customer names, card details, and order histories into a database any developer on the team can query — a liability a synthetic dataset never carries, since none of it describes a real person or a real purchase. It also goes stale the moment it's taken; regenerating a synthetic dataset costs one command, not a new export.
Why CSV for database seeding
A CSV file is the most portable way to get that dataset into a database, because every mainstream engine already has a bulk-import path built around it: PostgreSQL's COPY, MySQL's LOAD DATA INFILE, and the CSV import wizard built into most GUI database clients all read the same file format. The dataset generated below drops into whatever engine currently backs dev — no custom loader script, no per-database export format to maintain.
A destination that accepts inserts directly instead of a file can skip the CSV step entirely. Eventum's clickhouse output inserts each transaction into a table over HTTP as it's generated, so an analytical store built for that kind of write never needs a file in between — the Going further section revisits this once the CSV build below is in place.
What you'll build
The generator uses:
- linspace input — 10,000 timestamps spread evenly across a date range.
- chance picking mode — weighted random selection between three transaction types.
- file output with the default
plainformatter — each template already renders a complete CSV row, so nothing needs reshaping before it's written. - JSON samples — product catalog loaded from a file.
- shared state — running revenue counter across all events.
The entire dataset is generated in one burst using sample mode (live_mode: false), so 10,000 rows complete in seconds.
Prerequisites
Project structure
Build it
Create the project directory
mkdir -p ecommerce-csv/{templates,data}
cd ecommerce-csvCreate the product catalog
A JSON sample file with product names, categories, and price ranges. Each transaction picks a random product from this list.
[
{ "name": "Wireless Mouse", "category": "Electronics", "min_price": 15, "max_price": 45 },
{ "name": "USB-C Hub", "category": "Electronics", "min_price": 25, "max_price": 80 },
{ "name": "Desk Lamp", "category": "Home", "min_price": 20, "max_price": 60 },
{ "name": "Notebook Set", "category": "Office", "min_price": 8, "max_price": 25 },
{ "name": "Bluetooth Speaker", "category": "Electronics", "min_price": 30, "max_price": 120 },
{ "name": "Coffee Mug", "category": "Home", "min_price": 10, "max_price": 30 },
{ "name": "Backpack", "category": "Accessories", "min_price": 35, "max_price": 90 },
{ "name": "Webcam", "category": "Electronics", "min_price": 40, "max_price": 150 },
{ "name": "Plant Pot", "category": "Home", "min_price": 12, "max_price": 35 },
{ "name": "Mechanical Keyboard", "category": "Electronics", "min_price": 60, "max_price": 200 }
]Write transaction templates
Each template produces a single CSV row. All three templates share the same column order: transaction_id, timestamp, type, customer_id, customer_name, product, category, amount, currency. The {%- -%} markers around each set/do line trim the newline that would otherwise follow it, so the rendered event is exactly one line — without them, every rendered row would carry three blank lines ahead of it.
Purchase — the most common transaction. Picks a random product and generates a price within its range.
{%- set product = module.rand.choice(samples.products) -%}
{%- set amount = module.rand.number.floating(product.min_price, product.max_price) -%}
{%- do shared.set("revenue", shared.get("revenue", 0) + amount) -%}
{{ module.rand.crypto.uuid4() }},{{ timestamp.strftime("%Y-%m-%d %H:%M:%S") }},purchase,C-{{ module.rand.number.integer(10000, 99999) }},{{ module.faker.locale.en.name() }},{{ product.name }},{{ product.category }},{{ "%.2f" | format(amount) }},USDJSON sample rows support named access via object keys: product.name, product.category, product.min_price, product.max_price.
Refund — returns a product. The amount is negative.
{%- set product = module.rand.choice(samples.products) -%}
{%- set amount = module.rand.number.floating(product.min_price, product.max_price) -%}
{%- do shared.set("revenue", shared.get("revenue", 0) - amount) -%}
{{ module.rand.crypto.uuid4() }},{{ timestamp.strftime("%Y-%m-%d %H:%M:%S") }},refund,C-{{ module.rand.number.integer(10000, 99999) }},{{ module.faker.locale.en.name() }},{{ product.name }},{{ product.category }},-{{ "%.2f" | format(amount) }},USDChargeback — disputed transaction. Rare (5% of total).
{%- set product = module.rand.choice(samples.products) -%}
{%- set amount = module.rand.number.floating(product.min_price, product.max_price) -%}
{%- do shared.set("revenue", shared.get("revenue", 0) - amount) -%}
{{ module.rand.crypto.uuid4() }},{{ timestamp.strftime("%Y-%m-%d %H:%M:%S") }},chargeback,C-{{ module.rand.number.integer(10000, 99999) }},{{ module.faker.locale.en.name() }},{{ product.name }},{{ product.category }},-{{ "%.2f" | format(amount) }},USDThe shared.set("revenue", ...) call tracks a running total across all events. This doesn't appear in the CSV — it's an example of cross-template coordination. You could use it in a summary template or inspect it in the State view of the project console in Studio.
Configure the generator
The chance picking mode assigns each template a relative weight, distributing timestamps across the three transaction types.
input:
- linspace:
start: "2025-01-01T00:00:00"
end: "2025-12-31T23:59:59"
count: 10000
endpoint: true
event:
template:
mode: chance
samples:
products:
type: json
source: data/products.json
templates:
- purchase:
template: templates/purchase.jinja
chance: 0.80
- refund:
template: templates/refund.jinja
chance: 0.15
- chargeback:
template: templates/chargeback.jinja
chance: 0.05
output:
- file:
path: transactions.csv
write_mode: overwriteKey decisions:
- linspace spreads 10,000 timestamps evenly across the entire year 2025 — about one transaction every 53 minutes.
- chance mode: 80% of timestamps render the
purchasetemplate, 15%refund, 5%chargeback. - chance values are relative weights — 0.80/0.15/0.05 need not sum to 1; only the ratio between them matters (see chance picking mode).
- The file output uses
write_mode: overwriteso each run starts fresh. The defaultplainformatter passes each rendered template through unchanged — since every template already renders a full CSV row, there's nothing left to reshape.
Run it
Use eventum generate in sample mode for fast, one-shot generation:
eventum generate --path generator.yml --id ecommerce --live-mode falseThe --live-mode false flag releases all 10,000 timestamps instantly instead of waiting for their scheduled wall-clock times. The entire dataset generates in a few seconds.
Add a CSV header and check the output:
sed -i '1i transaction_id,timestamp,type,customer_id,customer_name,product,category,amount,currency' transactions.csv
head -6 transactions.csvA real run produced this — three purchases followed by two refunds:
transaction_id,timestamp,type,customer_id,customer_name,product,category,amount,currency
5d842291-9c9e-4c88-a9b0-76d3ae3e0ccb,2024-12-31 21:00:00,purchase,C-86918,Rachel Hill,Desk Lamp,Home,53.31,USD
df9d7962-6a0d-467a-888b-7221ab42b51f,2024-12-31 22:45:07,purchase,C-94340,David Evans,Mechanical Keyboard,Electronics,168.20,USD
c120dd12-cfe3-4e95-b585-d273e52b626e,2024-12-31 21:52:33,purchase,C-34622,Jacob Smith,Webcam,Electronics,71.57,USD
8ef4f5d0-99a9-47d3-9920-e387d699987e,2024-12-31 23:37:41,refund,C-16314,Ricky Ewing DVM,Mechanical Keyboard,Electronics,-128.78,USD
4fc84fe8-be85-4920-8681-f9c389e67b68,2025-01-01 00:30:15,refund,C-72557,Andrea Barnes,Wireless Mouse,Electronics,-43.52,USDElsewhere in the same run, the rarer chargeback case shows up too:
8b11c3e4-2c40-4d20-8063-82fcb0a7bc6d,2025-01-01 17:09:00,chargeback,C-36012,Justin Russell,Plant Pot,Home,-17.73,USDCheck the distribution across the full file:
tail -n +2 transactions.csv | cut -d',' -f3 | sort | uniq -c | sort -rn 7977 purchase
1560 refund
463 chargebackThat's 79.8% / 15.6% / 4.6% against a configured 80/15/5 — close, with the small spread expected from a chance-weighted pick rather than a fixed count.
Seed staging environment from the CSV
The header written in the previous step already names every column a transactions table needs, so the file drops straight into a bulk-import command instead of a custom loader script.
PostgreSQL reads it with \copy, the client-side form of COPY that works against a local file without needing server-side filesystem access:
\copy transactions FROM 'transactions.csv' WITH (FORMAT csv, HEADER true)MySQL uses LOAD DATA:
LOAD DATA LOCAL INFILE 'transactions.csv'
INTO TABLE transactions
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES;Most GUI database clients — pgAdmin, DBeaver, TablePlus, MySQL Workbench — wrap the same operation behind an "Import from CSV" wizard, for a one-off load that doesn't need a saved command.
None of that changes when the target shifts: the same command seeds a staging environment exactly the way it seeds dev, pointed at a different connection string. Regenerating a fresh CSV whenever a schema change or a new test scenario calls for different data costs one command — waiting on the next production snapshot doesn't.
Going further
- Add customer loyalty — use
sharedstate to track repeat customers by keeping a customer pool and reusing IDs with some probability. - Seasonal patterns — replace
linspacewith time-patterns using a triangular distribution to create Black Friday and holiday spikes. - Multiple currencies — add
paramsfor currency and run multiple generators in parallel with different currency settings. - Seed an analytical store directly — swap the
fileoutput for ClickHouse and every transaction inserts straight into a table over HTTP as it's generated, with no CSV or import step in between.
What's next
linspace reference
Evenly-spaced timestamp generation for bounded datasets.
Formatters
Template, JSON, and batch formatters for reshaping output.
File output
Writing events to local files with configurable formatting.
FAQ
Related
- The Realistic values lesson for the distributions and weighted choices behind the price ranges and the 80/15/5 transaction mix
- The ClickHouse delivery lesson for seeding an analytical database directly, with no CSV in between
- The Test data pipeline lesson for testing transport correctness instead of seeding a database at rest
- The Scenarios pillar for the course's other synthetic-data scenarios
- The Eventum Hub for datasets already shaped for common sources, ready to load without building one first
Test Sigma Rules with Synthetic Attack Telemetry
Generate benign and attack-shaped telemetry to test Sigma rules and ATT&CK-mapped detections, without running a real attack or touching production.
Generate Realistic Load Test Data for an API
Generate realistic load test data — a diverse, weighted mix of request payloads — and fire it at a REST API in sample mode to benchmark throughput and find its breaking point.