You clone the project, run the setup, and open the dashboard. There are users. There is a leaderboard. There are payments. None of them have anything to do with each other — user 4 is on the leaderboard, user 11 has a subscription, and nobody has both. You cannot demo it, you cannot test the screen that joins them, and every bug you find might just be the fixtures.
Seed data usually gets built as an afterthought, one pile per table. pithy seed is built the other way round.
The same shape as migrations
The mental model is deliberately borrowed: pithy seed is the direct analog of pithy migrate.
A migration describes schema changes. A seed set describes rows, entries and assets. Both are namespaced per capability, both are ordered, both compose library-before-app into a single registry, and both run from one CLI command against a named --env.
If you already know how migrations work here, you already know how seeding works. Where a migration’s unit of work is a table change, a seed set’s is a batch of fixtures across D1, KV, R2, and the shared media stores — Images and Stream.
You author one with defineSeed, the peer of defineCapability.
The three people
Here is the part that changes what seeded data is for.
@pithy-sh/core exports EXAMPLE_IDENTITIES — three demo users with stable ids and example.com addresses. Ada, Grace and Alan. Every capability’s example seed set references that same cast:
| capability | what the cast gets |
|---|---|
| auth | all three, as verified passwordless users |
| leaderboard | scores for each on a demo board |
| ledger | an opened balance in a demo coins currency |
| payments | Ada a live Apple subscription, Grace a Stripe non-consumable, Alan a refunded Google consumable |
| multiplayer | one resolved match, which Ada wins |
| audit | a timeline attributed to them — a login, a denied attempt, an entitlement grant, an admin change, a critical token-reuse alert, a debit |
Look at the payments row. That is not three arbitrary purchases — it is one per rail and one per product type, chosen so that whatever screen you are building has an example of the case you need.
So a fresh project comes up with a backend where the same three people own connected data across every table. You can open the dashboard and see a real timeline. You can build the screen that joins a user to their entitlement and their standing, and have something to render into it. The point of seed data, made visible.
How that works without coupling capabilities
There is a real design problem hiding in that table. If leaderboard needs Ada’s user id, does leaderboard now depend on auth?
No — and the rule that prevents it is one the whole kit follows: a capability may depend on a core seam, never on a sibling.
EXAMPLE_IDENTITIES lives in core, the one dependency every capability already has. Each set reads the ids from there, never from @pithy-sh/auth. Compose order does the rest: auth seeds at order 100, and the sets referencing those users run at 200–300, so the people exist before anything points at them.
If you author your own connected fixtures, do the same thing. Pick stable ids in one shared module and reference them everywhere. It is a small discipline that buys you data you can actually demo.
Three layers between a fixture and production
Seeding writes to a real database, so the interesting question is what stops it writing to the wrong one.
Nothing here trusts a single check. There are three, each enforced at a different point, so bypassing one still leaves the others.
1. The allowlist, at compose. A seed set only joins a run’s registry if its own environments array lists the target --env. This is not a flag you pass at the command line — it is authored once, on the fixture, by whoever wrote it. production is never in a set’s environments unless somebody deliberately typed it there.
2. The same allowlist, again at write time. Compose already filtered the disallowed sets out. The write path re-checks anyway, immediately before writing. So a set cannot reach a disallowed environment through any code path that skips compose.
3. Escalating confirmation, at the command. dev runs freely. staging and other non-production names need --yes. Production needs --yes and an exact match of the phrase:
yes, i really want to seed productionTyped interactively when a human is at a terminal, or passed with --confirm-production in CI.
Even an authorized mistake cannot destroy anything
The fourth protection is not a check at all — it is the shape of every write.
D1 fixtures insert with INSERT OR IGNORE. KV entries are written by key. Media marked once uploads exactly once, ever.
So every seed run is idempotent and non-destructive. Run it twice and the second run changes nothing. Run it against the wrong environment with all three gates satisfied, and the worst outcome is fixtures that were not there before. It cannot overwrite a row, and it cannot delete one.
That is worth designing for explicitly. The gates stop the run you should not have started. Idempotence limits the damage of the run you started anyway.
Examples are off until you ask
One last default worth knowing. Example sets are flagged example: true and are off unless you turn them on:
seed: { includeExamples: true }With the flag off, they are not merely skipped — they are absent from the registry entirely, so pithy seed --dry-run never even lists them. And an example set never targets production regardless of the setting. It is a way to see a working backend on day one, not a data source.