Seed a datastore with test data
A realistic dataset in an environment, loaded from a file you can read and version, and safe to run twice.
When you finish a new environment goes from empty to usable in one command, and so does yours after you wipe it.
Why bother
Section titled “Why bother”Hand-made test data is the quiet tax on every feature.
| Without this | With this |
|---|---|
| Everyone’s local environment holds different data | One file, one command, same everywhere |
| A bug reproduces on your machine and nowhere else | The dataset is part of the repository |
| Re-running the seed duplicates rows | Idempotent by key |
| Demo data is typed in the morning of the demo | It is checked in |
Keep the data in CSV, not code. Data in a file is reviewable in a diff, editable by someone who does not write JavaScript, and reusable by the migration and reporting guides that follow.
Before you start
Section titled “Before you start”| You need | Why |
|---|---|
| A running Data API with your entity declared | The seed writes through it |
| A token for the target environment | Every write is authenticated |
| A CSV of the rows you want | The input |
customers.csv:
email,plan,seatsStep 1 — guard the target
Section titled “Step 1 — guard the target”The single most important line in a seed is the one that stops it running against production.
name: seed-customersvars: environment: dev api: https://data.dev.example.internaltasks: - name: guard choice: rules: - condition: "environment !== 'production'" next: load default: refuse
- name: refuse fail: error: WrongEnvironment cause: "seed refused: environment is production"function main() { if (environment === 'production') { throw new Error('seed refused: environment is production'); } // …}environment arrives as a bare global from --vars or --env.
Make it a positive allowlist if the data is sensitive: refuse anything that is not explicitly
dev or test. A denylist protects you from the environment you thought of.
Step 2 — read the rows
Section titled “Step 2 — read the rows”A tables: block turns the CSV into rows, and the top-level table: key runs the whole flow once
per row with each column bound as a variable:
tables: customers: type: csv file: ./customers.csvtable: customersInside the tasks, {{email}}, {{plan}} and {{seats}} are the current row.
const rows = csv.read({ path: './customers.csv' }).records;// -> [{ email: '[email protected]', plan: 'pro', seats: '25' }, …]csv.read returns {success, records}. Every value is a string — CSV has no types — so coerce
anything you need as a number before sending it.
Step 3 — write each row
Section titled “Step 3 — write each row”tasks: - name: load http: url: "{{api}}/data/customer" method: POST headers: Content-Type: application/json Authorization: "Bearer {{token}}" payload: '{"email":"{{email}}","plan":"{{plan}}","seats":{{seats}}}' setstatuscode: code next: go: report
- name: report print: message: "{{email}} -> {{code}}"Note {{seats}} is unquoted in the payload and the others are quoted — the template writes the
value in literally, so quoting is how you choose between a JSON string and a JSON number.
function main() { if (environment === 'production') throw new Error('seed refused');
const rows = csv.read({ path: './customers.csv' }).records; const results = [];
for (const row of rows) { const r = http.post({ url: `${api}/data/customer`, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ email: row.email, plan: row.plan, seats: Number(row.seats), }), }); results.push({ email: row.email, status: r.statusCode }); }
return { seeded: results.length, results };}Number(row.seats) is the coercion the flow does with quoting.
Step 4 — make it safe to run twice
Section titled “Step 4 — make it safe to run twice”A seed you cannot re-run is a seed you will hesitate to use.
The cleanest approach is to make the write idempotent on a natural key — an upsert on email
rather than a blind create. Where the API does not offer one, check first and skip:
- name: exists http: url: "{{api}}/data/customer?filter=email:eq:{{email}}" method: GET setbody: found setstatuscode: code next: go: decide
- name: decide choice: rules: - condition: "found.total > 0" next: skip default: load
- name: skip print: message: "{{email}} already present, skipping"const found = http.get({ url: `${api}/data/customer?filter=email:eq:${encodeURIComponent(row.email)}`, headers: { Authorization: `Bearer ${token}` },});if (JSON.parse(found.body).total > 0) { log.info(`${row.email} already present, skipping`); continue;}Check-then-write is not atomic. Two seeds racing can both see “not present” and both create. For a seed against a dev environment that is fine and the simplicity is worth it; for anything where a duplicate matters, use an upsert and let the database enforce the constraint.
Which surface to use
Section titled “Which surface to use”Genuinely different answers here, and the file size decides:
| The dataset | Use |
|---|---|
| A few hundred rows | Either. The script is shorter |
| Tens of thousands | A flow, so a failure at row 40,000 does not mean starting over |
| Needs shaping before it lands | A script — the transform is code, and code is where transforms belong |
| Runs in CI on every environment build | A flow, for the record of what completed |
Verify
Section titled “Verify”Count what you loaded, from the other side:
curl -fsS "$API/data/customer?limit=0" -H "Authorization: Bearer $TOKEN" | jq .totalThen run the seed again. The count must not change — that is the whole of step 4, checked in one command.
Adapt it
Section titled “Adapt it”| Change | How |
|---|---|
| More entities | One CSV and one node or loop each; keep them in the same flow so one command seeds everything |
| Bigger volumes | Move to a data pipeline — it streams rather than loading |
| Deterministic ids | Put them in the CSV. Generated ids make a seed non-reproducible |
| Wipe before seeding | A DELETE pass first — and guard it with the same environment check, doubled |
Related
Section titled “Related”- Run a data migration safely — the same shape, on data that already matters
- Batch-process a directory of files
- Data API — the entities you are writing into