What seeding is for
A freshly migrated database has the right tables and no rows. Seeding fills it with data, and the right data depends on who is using it:
- Local development needs a few known accounts you can log in with and enough content to make pages look real.
- Demos and previews need believable data that is safe to show, never a copy of production.
- Tests need precise, minimal data that each test controls.
- CI needs all of the above to run unattended, the same way every time.
The common thread is repeatability. A seed you can only run once on an empty database will break the first time someone runs it twice, so this guide treats idempotency as a requirement, not a bonus.
A typed seed file with defineSeed
kastor seeds are plain TypeScript. Point the seed option in kastor.config.ts at a file, for example seed: './src/db/seed.ts', and export a function wrapped in defineSeed. The CLI calls it with your client. Pass the client type as the type argument (export type Db = typeof db from your db module) and every column name and value is checked by the compiler, exactly like application code.
import { defineSeed } from 'kastor-orm'
import type { Db } from './index'
export default defineSeed<Db>(async (db) => {
// Fixed records: upsert keyed on a unique column, safe to run again
const admin = await db.users.upsert({
where: { email: 'admin@example.com' },
create: { email: 'admin@example.com', name: 'Admin', role: 'admin' },
update: { role: 'admin' },
})
await db.users.upsert({
where: { email: 'reader@example.com' },
create: { email: 'reader@example.com', name: 'Reader' },
update: {},
})
console.log(`seeded admin #${admin.id}`)
})Run it with the CLI. It loads your config, connects with the configured adapter, builds a client from the tables of your schema module and calls the default export with it. That client takes its relations from a relations export of the schema module, which is where kastor init and db pull put them, so keep them there if the seed uses nested writes or include:
npx kastor migrate dev --name init
npx kastor db seed
npx kastor migrate resetThe last line is the "start over" routine: migrate reset asks for confirmation, drops every table, rebuilds the database from your migrations and then runs the seed from the config, so there is no need to call db seed after it. Pass --skip-seed when you want empty tables, and --force to skip the question in scripts. Because the schema comes from committed migrations, everyone on the team gets the same starting point.
Idempotent seeds with upsert
An idempotent seed produces the same result whether it runs once or ten times. That property makes it safe to run after every pull, in a post-deploy step for preview environments, or in a CI job that may retry.
upsert is the core tool. You give it a where on a unique column, the row to create if nothing matches, and the fields to update if it does. On databases with a native upsert kastor uses it, so there is no race between checking and inserting. An empty update means "leave existing data alone", which is what you want for accounts a developer may have edited by hand.
Some rules that keep seeds idempotent:
- Key fixed records on a natural unique column, such as an email or a slug, not on an auto increment id that differs between databases.
- Guard bulk data with a
countcheck, since inserting 200 generated rows twice would either fail on unique constraints or duplicate everything. - Avoid reading ids from previous runs. Look the row up by its unique key, then use the returned id.
Bulk inserts and related rows
For volume, createMany inserts many rows in as few statements as the database allows instead of one round trip per row, and resolves to { count }. Combine it with deterministic data: fixed dates and a seeded random generator, so every run and every machine produces identical rows. That turns "the bug only shows up with my seed" into something anyone can reproduce.
// Small seeded PRNG (mulberry32): same seed, same sequence, every run
function seeded(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// inside the defineSeed callback
const random = seeded(42)
const start = new Date('2026-01-01T00:00:00Z').getTime()
const existing = await db.users.count({ where: { email: { startsWith: 'member' } } })
if (existing === 0) {
await db.users.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
email: `member${i}@example.com`,
name: `Member ${i}`,
role: random() < 0.05 ? ('admin' as const) : ('user' as const),
createdAt: new Date(start + i * 3_600_000),
})),
})
}Related data reads best as a nested write. Creating a user together with their posts runs inside a transaction, so you never end up with half a fixture. To attach a new row to a parent that already exists, use connect on the relation:
// inside the defineSeed callback, after the admin upsert
await db.users.create({
data: {
email: 'author@example.com',
name: 'Ada Writer',
posts: {
create: [
{ title: 'Hello world', body: 'First post.', published: true },
{ title: 'Draft ideas' },
],
},
},
})
await db.posts.create({
data: {
title: 'Welcome to the blog',
published: true,
author: { connect: { id: admin.id } },
},
})In a real seed, give the author an upsert too, or check for the email first, so this block stays idempotent. Setting the foreign key directly with authorId: admin.id also works; connect reads better when the relation name says more than the column.
Keeping a growing seed manageable
Seed files tend to grow until nobody wants to touch them. A few habits keep them readable:
- Split by domain. Write small async functions such as
seedUsers(db)andseedPosts(db)in separate modules and call them in order from thedefineSeedcallback. The callback becomes a table of contents. - Insert parents before children. Foreign keys require the referenced row to exist, so users come before posts. If a step needs ids from an earlier one, return them from the earlier function instead of querying for them again.
- Go all or nothing when it matters. Wrap related steps in
db.$transaction(async (tx) => ...)and usetxinside, so a failure halfway leaves no partial data behind. - Refuse to run in the wrong place. Check an environment variable at the top of the seed and throw if it points at production. A seed that creates an admin with a known password must never reach a real database.
- Keep secrets out. Seed passwords and API keys are test values only. Read anything sensitive from the environment rather than committing it.
If a seed failure is a database error, kastor throws a KastorError with a plain English message and a hint. A UNIQUE_VIOLATION during seeding almost always means a step is not idempotent yet.
Factories for tests
Tests should not depend on the shared seed. If a test assumes "there are 200 members" and someone changes the seed to 50, an unrelated test fails. Instead, each test creates exactly the rows it asserts on. Factories make that cheap.
A factory pairs a model of your client with a defaults function. The function receives a context with a sequence number seq (1, 2, 3 and so on) and returns create data, so every row is unique (no email collisions) and predictable (user 3 is always user3@example.com). The return value is checked against the model's create type.
import { factory } from 'kastor-orm'
import { db } from '../src/db'
export const userFactory = factory(db.users, ({ seq }) => ({
email: `user${seq}@example.com`,
name: `User ${seq}`,
}))
export const adminFactory = factory(db.users, ({ seq }) => ({
email: `admin${seq}@example.com`,
name: `Admin ${seq}`,
role: 'admin' as const,
}))
// in a test
const ada = await userFactory.create({ name: 'Ada' }) // one row, with overrides
const crowd = await userFactory.createMany(10) // ten rows
const draft = userFactory.build() // data only, nothing saved
userFactory.reset() // seq starts at 1 againDefine one factory per shape you test often, rather than one giant factory with flags. buildMany(count) returns data for several rows without saving them, handy for a createMany bulk insert, and both createMany and buildMany accept overrides as an object or as a function of the index.
Resetting between tests
Rebuilding the schema with migrate reset for every test is far too slow. Run migrations once per test run, then empty the tables in a setup hook. On PostgreSQL a single TRUNCATE does it:
import { beforeEach, afterAll } from 'vitest'
import { db } from '../src/db'
beforeEach(async () => {
await db.$execute`TRUNCATE TABLE "posts", "users" RESTART IDENTITY CASCADE`
})
afterAll(async () => {
await db.$disconnect()
})RESTART IDENTITY resets auto increment counters, which keeps ids deterministic from test to test. Never point this file at a database you care about: use a dedicated test database URL.
Seeding in CI
CI starts from nothing every run, which makes it the most honest check that your migrations and seed work. A typical job starts an empty database, applies migrations with migrate deploy, runs the seed if your end to end tests rely on it, and then runs the tests:
- Start a database service container with a throwaway password.
- Run
npx kastor migrate deployto build the schema from committed migrations. - Run
npx kastor db seedfor end to end or preview environments. - Run the test suite, which uses factories for its own rows.
Running the seed twice in CI once in a while is a cheap way to prove it is idempotent. For more on the migration side, read database migrations explained, and to look at what the seed produced, open npx kastor studio locally.
Questions people ask
Q1How do I seed a database in TypeScript?
Write a seed file that exports defineSeed(async (db) => { ... }), point the seed option of kastor.config.ts at it, and run npx kastor db seed. The db argument is a fully typed client for your schema.
Q2How do I make a seed script idempotent?
Use upsert keyed on a unique column for fixed records, and guard bulk inserts with a count check. Running the seed twice then leaves the database in the same state instead of failing on unique constraints or duplicating rows.
Q3Should tests use the seed file or factories?
Use factories for tests. Each test should create exactly the rows it needs, so it does not break when someone changes the shared seed. Keep the seed file for local development and demos.
Q4How do I reset the database between tests?
Locally, npx kastor migrate reset drops every table, rebuilds the schema from your migrations and runs the configured seed again. Inside a test suite, clearing tables in a setup hook is much faster than rebuilding the schema for each test.
Q5How do I seed related rows like users with posts?
Use a nested write: db.users.create with posts: { create: [...] } creates the user and its posts together. To attach a row to an existing parent, use connect: { id } on the relation.