Skip to content
Talk to our solutions team

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.

Hand-made test data is the quiet tax on every feature.

Without thisWith this
Everyone’s local environment holds different dataOne file, one command, same everywhere
A bug reproduces on your machine and nowhere elseThe dataset is part of the repository
Re-running the seed duplicates rowsIdempotent by key
Demo data is typed in the morning of the demoIt 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.

You needWhy
A running Data API with your entity declaredThe seed writes through it
A token for the target environmentEvery write is authenticated
A CSV of the rows you wantThe input

customers.csv:

email,plan,seats
[email protected],enterprise,400

The single most important line in a seed is the one that stops it running against production.

name: seed-customers
vars:
environment: dev
api: https://data.dev.example.internal
tasks:
- name: guard
choice:
rules:
- condition: "environment !== 'production'"
next: load
default: refuse
- name: refuse
fail:
error: WrongEnvironment
cause: "seed refused: environment is production"

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.

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.csv
table: customers

Inside the tasks, {{email}}, {{plan}} and {{seats}} are the current 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.

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"

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.

Genuinely different answers here, and the file size decides:

The datasetUse
A few hundred rowsEither. The script is shorter
Tens of thousandsA flow, so a failure at row 40,000 does not mean starting over
Needs shaping before it landsA script — the transform is code, and code is where transforms belong
Runs in CI on every environment buildA flow, for the record of what completed

Count what you loaded, from the other side:

Terminal window
curl -fsS "$API/data/customer?limit=0" -H "Authorization: Bearer $TOKEN" | jq .total

Then run the seed again. The count must not change — that is the whole of step 4, checked in one command.

ChangeHow
More entitiesOne CSV and one node or loop each; keep them in the same flow so one command seeds everything
Bigger volumesMove to a data pipeline — it streams rather than loading
Deterministic idsPut them in the CSV. Generated ids make a seed non-reproducible
Wipe before seedingA DELETE pass first — and guard it with the same environment check, doubled