The first seed script I ever wrote ran fine once, then I ran it again while testing something unrelated and ended up with six “Meeting Room” rows in my resource types table. Nothing broke loudly, it just quietly made my dropdown menus look ridiculous until I noticed. Since then, I’ve been a lot more careful about how I structure seed scripts, and this post walks through the approach I use now, built around a resource booking system I worked on (think meeting rooms, company vehicles, equipment, desks).
If you’ve set up your DataSource already (I covered that in my last post on TypeORM configuration), this is the natural next step: getting realistic test data into your tables without making a mess of it.
Why seed scripts matter more than people think
It’s tempting to just click around your app manually and add a few test rows through your own UI. That works for about a day. The moment you need to test a “cancelled booking” state, or a user with a specific department, or what happens when fifty bookings exist for one room, manual data entry becomes the actual bottleneck in your workflow. A seed script gives you the same starting data every time, which makes bugs reproducible instead of “well, it worked when I tested it.”
Guard against duplicate seeding first
This is the part I learned the hard way, so it’s where I start now, before writing a single insert.
const existingTypes = await typeRepo.count();
if (existingTypes > 0) {
console.log('[seed] data already present — skipping. Truncate tables to re-seed.');
await AppDataSource.destroy();
return;
}
Before inserting anything, check whether the data already exists. If it does, bail out instead of duplicating rows. I use a count on one core table (ResourceType in this case) as a stand-in check for “has this script already run.” It’s not bulletproof, if someone manually wipes one table but not others, the check won’t catch it, but for a script that mainly runs in local dev or CI setup, it covers the common case without overengineering it.
Structure your inserts around actual foreign key order
Seed data has dependencies, just like your schema does. You can’t create a Resource row pointing at a ResourceType that doesn’t exist yet. So the order in the script should mirror the order your foreign keys actually require:
const types = await typeRepo.save([
typeRepo.create({ name: 'Meeting Room', description: 'Rooms for meetings and discussions' }),
typeRepo.create({ name: 'Vehicle', description: 'Company cars and vans' }),
typeRepo.create({ name: 'Equipment', description: 'Shared equipment and devices' }),
typeRepo.create({ name: 'Workspace', description: 'Hot desks and dedicated workspaces' }),
]);
const [meetingRoom, vehicle, equipment, workspace] = types;
The detail that’s easy to miss here: save() returns the inserted rows with their generated IDs already populated. That’s why I can immediately destructure the array into named variables and use meetingRoom.id later, without a separate query to fetch what I just inserted. If you’re coming from raw SQL where an insert doesn’t hand you anything back by default, this is one of the genuinely convenient parts of using an ORM.
Linking resources back to their types
const resources = await resourceRepo.save([
resourceRepo.create({
name: 'Orion Conference Room',
location: 'Floor 3, East Wing',
capacity: 12,
resourceTypeId: meetingRoom.id,
}),
resourceRepo.create({
name: 'Epson EX3260 Projector',
location: 'IT Store Room',
capacity: null,
resourceTypeId: equipment.id,
}),
// ...
]);
I’m assigning resourceTypeId directly here instead of attaching the full relation object. Some TypeORM tutorials will tell you to do resourceType: meetingRoom and let the ORM handle the relation. Both work, but when you already have the ID sitting in a variable, assigning it directly is faster to write and doesn’t require TypeORM to resolve the relation object during the insert.
Also worth pointing out: capacity: null for the projector. It’s a small thing, but it matters, a camera doesn’t have a seating capacity, and forcing a 0 into that field would be technically wrong and could cause weird display bugs later (“Capacity: 0 people” reads like an error message). If your column allows nulls, use them where they’re semantically correct.
Making timestamps actually useful
This is the bit that made the biggest difference in how useful my seed data actually was for testing:
const hoursFromNow = (hours) => new Date(Date.now() + hours * 60 * 60 * 1000);
Instead of hardcoding a date like new Date('2026-01-15'), which becomes stale and meaningless a month after you write it, this generates a timestamp relative to right now. So hoursFromNow(24) is always “tomorrow,” and hoursFromNow(-48) is always “two days ago,” no matter when you run the script.
That matters because a booking system needs to test both upcoming and past bookings, and if your seed data has a hardcoded past date, eventually your “upcoming” bookings will silently become past ones too, and you won’t notice until your dashboard starts looking wrong for no obvious reason.
I also seed at least one cancelled booking, not just active ones:
bookingRepo.create({
title: 'Cancelled marketing shoot',
resourceId: resources[6].id,
userId: users[1].id,
startTime: hoursFromNow(72),
endTime: hoursFromNow(75),
status: BOOKING_STATUS.CANCELLED,
cancelledAt: new Date(),
}),
If every row in your seed data represents the “happy path,” you’ll never catch bugs in how your app handles cancelled, expired, or otherwise non-standard states until a real user hits them in production. Seed a few edge cases on purpose.
Closing the connection and failing loudly
await AppDataSource.destroy();
console.log('[seed] done');
seed().catch((error) => {
console.error('[seed] failed', error);
process.exit(1);
});
Two small habits here that I’d consider non-negotiable. First, explicitly destroy the connection when you’re done. Seed scripts are meant to run once and exit, leaving a pool open means the process hangs instead of finishing cleanly. Second, wrap the whole thing in a .catch() that exits with a non-zero code on failure. If this script is wired into a CI step or a setup command, a silent failure that still exits with code 0 is worse than no seed script at all, because it tells your pipeline everything’s fine when it isn’t.
Where I’d improve this next
If I were revisiting this script today, I’d extend the “already seeded” check to cover more than one table, since right now it only checks ResourceType. It works for the common case but leaves a gap if someone manually clears a different table. Small thing, but it’s the kind of detail that turns into a confusing bug report six months from now if it’s left alone.
