Designing an Idempotent Prisma Seed for Production
When you deploy a fresh application, the database is empty. You need some initial content: a list of projects, default settings, maybe an admin user. The obvious solution is a seed script. The less obvious problem is what happens when that seed script runs again.
The Problem with Naive Seeds
A naive seed looks something like this:
for (const project of projects) {
await prisma.project.create({ data: project });
}
Run it once, everything works. Run it twice, you get duplicates. Run it every time your Docker container restarts (which is exactly what happens with a standard entrypoint), and you end up with the same projects repeated dozens of times.
The first instinct is to add a deleteMany() before the loop:
await prisma.project.deleteMany({});
for (const project of projects) {
await prisma.project.create({ data: project });
}
This prevents duplicates, but it destroys any changes you made through the admin panel. If you updated a project description through the UI, that edit is gone the next time the container restarts.
What Idempotency Means Here
An idempotent seed produces the same result regardless of how many times it runs. Specifically:
- If a project does not exist, create it
- If a project already exists (identified by its slug), do nothing
- Never delete anything
- Never overwrite manual edits
This sounds simple, but the implementation requires care.
Using Upsert Correctly
Prisma's upsert is the right tool here. It takes a where clause to find an existing record, an update for when it exists, and a create for when it does not.
The key insight is that the update should be empty:
await prisma.project.upsert({
where: { slug: project.slug },
update: {},
create: {
title: project.title,
slug: project.slug,
description: project.description,
// ...
},
});
With update: {}, an existing project is found by its slug and then... nothing happens. No fields are modified. The manual edits are preserved. Only when the slug is not found does the create branch execute.
Handling Tags
Tags are trickier because they have their own table and a many-to-many relationship with projects. You cannot just pass an array of strings; you need to connect existing tags or create new ones.
Prisma's connectOrCreate handles this:
await prisma.project.upsert({
where: { slug: project.slug },
update: {},
create: {
title: project.title,
slug: project.slug,
tags: {
connectOrCreate: project.tags.map((name) => ({
where: { name },
create: { name },
})),
},
},
});
For each tag name, Prisma checks if a Tag with that name exists. If yes, it connects it. If no, it creates it first, then connects it. This means running the seed twice will not create duplicate tags either.
One important detail: the Tag model must have a @unique constraint on the name field. Without it, connectOrCreate's where clause cannot reliably find existing tags, and you will end up with duplicates.
Detecting Created vs. Existing
For logging purposes, I want to know whether each project was created or already existed. The trick is to check before the upsert:
const existing = await prisma.project.findUnique({
where: { slug: project.slug },
select: { id: true },
});
await prisma.project.upsert({
where: { slug: project.slug },
update: {},
create: { /* ... */ },
});
if (existing) {
existingCount++;
} else {
createdCount++;
}
This adds one extra query per project, but for a seed that runs once per deployment, the performance cost is irrelevant. The clarity in the logs is worth it.
Migrations Before Seed
The seed must run after the database schema is up to date. In my Docker entrypoint, the sequence is:
prisma db push --skip-generate --schema /app/prisma/schema.prisma
npx tsx /app/apps/frontend/scripts/seed-portfolio-projects.ts
If the schema has changed (a new column, a new table), prisma db push applies those changes first. The seed then runs against the correct schema. Reversing this order would cause the seed to fail with missing table or column errors.
Concurrent Startup
What happens if two containers start at the same time and both try to seed? With SQLite, concurrent writes are serialized at the file level, so one will wait while the other completes. The @unique constraint on slug provides a safety net: even if both containers try to create the same project simultaneously, the database will reject the duplicate.
With PostgreSQL, the situation is similar but the mechanism is different. The unique constraint prevents duplicates, and upsert handles the race condition by catching the conflict and executing the update branch instead.
Verifying Idempotency
The simplest test is to run the seed twice and check the database:
npm run db:seed:portfolio
npm run db:seed:portfolio
After the second run, the log should show "Already existing: 9" and "Projects created: 0". The total count should remain the same.
To verify that manual edits are preserved, I update a project's title directly in the database, re-run the seed, and confirm the title has not changed. This is the test that catches the deleteMany() antipattern.
The Seed Data File
The project data lives in a separate TypeScript file with a typed interface:
export interface PortfolioProjectSeed {
title: string;
slug: string;
description: string;
excerpt: string;
githubUrl: string | null;
liveUrl: string | null;
image: string | null;
date: string;
order: number;
tags: string[];
}
Keeping the data separate from the seeding logic makes both easier to maintain. Adding a new project means adding an object to the array, not modifying the seeding function.
What This Does Not Solve
An idempotent seed is not a migration system. It does not handle schema changes, it does not version data, and it does not support rollback. For those, you need Prisma Migrate or a dedicated migration tool.
The seed also does not handle deletion. If you remove a project from the seed data, the seed will not delete it from the database. This is intentional: the seed should not be responsible for removing content that may have been manually created through the admin panel.
The pattern is simple once you see it, but getting it wrong can cause silent data loss or unexpected duplication. For a system that runs automatically on every deployment, "it works on the first run" is not enough.