🌱 Data Seeding Strategies
Migrations build the shelves; seeding stocks them. A freshly migrated database is structurally perfect and completely empty — no users to log in as, no products to list, no categories to pick from. Seeding is how you fill it with the right data for each environment: essential reference data everywhere, rich realistic data in development, and nothing accidental in production.
Week 8 · Day 5 (Friday: Database Migrations and Seeding) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish reference, development, test, and demo data and know where each belongs
- Write idempotent seeds that are safe to run repeatedly
- Guard seeds by environment so test data never lands in production
- Create and order seed files with Knex, respecting foreign-key dependencies
- Generate large volumes of realistic data with Faker.js and batch inserts
- Wire seeding into a CI/CD pipeline with per-environment scripts
Estimated Time: 70 minutes
Practice: Build a layered seed set — reference data plus Faker-generated development data — that's safe to re-run.
In This Lesson
What Is Seeding?
Data seeding is the process of populating a database with an initial set of records. Where migrations evolve the structure, seeds provide the starting content — the data your app needs to be usable, testable, or demonstrable. Together they form a complete database setup: migrate to build, seed to fill.
📚 Analogy: opening a new library
Migrations construct the library — the floor plan, the shelves, the classification system. Seeding is stocking it with books. Reference seeding is the essential reference section every library must have. Development seeding is a broad sample collection so the staff can practice. Test seeding is a few controlled records to verify the catalog system works. Without books, the building is structurally complete but useless.
Types of Seed Data
Not all seed data serves the same purpose, and mixing the categories up is how test users end up in a production database. Learn the four kinds.
| Type | Purpose | Where it runs |
|---|---|---|
| Reference data | Essential, stable lookup values the app needs to function (countries, roles, statuses) | Every environment, including production |
| Development data | Rich, realistic content so developers can work locally (sample users, products, orders) | Development only |
| Test data | Consistent, predictable records covering edge cases for automated tests | Test/CI only |
| Demo data | Polished, curated data that showcases the product | Staging/demo environments |
💡 Real-world: an e-commerce platform
- Reference: product categories, shipping methods, tax codes, currency list — needed for the store to run at all.
- Development: hundreds of realistic products, sample customers, order histories.
- Test: a user with each role, products with known prices to verify discount math, orders in every status.
- Demo: a handful of hand-picked premium products with great photos for the sales team.
Idempotency & Environment Guards
Two properties separate professional seeds from scripts that corrupt a database on the second run.
Idempotent = safe to re-run
An idempotent seed produces the same result no matter how many times you run it — no duplicate rows, no unique-constraint crashes. The simplest way to achieve this with Knex is to clear the table before inserting, so a re-run replaces rather than accumulates:
exports.seed = async function (knex) {
await knex('roles').del(); // clear first...
await knex('roles').insert([ // ...then insert a known set
{ id: 1, name: 'user' },
{ id: 2, name: 'admin' },
]);
};
For reference data that other tables point to, a delete-then-insert can trip foreign keys. There, prefer an upsert — insert, and on a conflicting key, update instead:
// PostgreSQL upsert via Knex: idempotent without deleting referenced rows
exports.seed = async function (knex) {
await knex('roles')
.insert([
{ id: 1, name: 'user' },
{ id: 2, name: 'admin' },
])
.onConflict('id') // if the id already exists...
.merge(); // ...update the row instead of erroring
};
Environment guards
The second property: a seed must know where it is. Development seeds should refuse to run in production. A one-line guard does it:
exports.seed = async function (knex) {
// Never insert bulk fake data into production.
if (process.env.NODE_ENV === 'production') {
console.log('Skipping development seed in production.');
return;
}
// ...generate development data below
};
⚠️ The nightmare scenario
A seed file that runs knex('users').del() and then inserts fake users — with no environment guard — gets run against production during a botched deploy. Every real user is gone. Environment guards on destructive/development seeds are not optional; they're the seatbelt.
Seeding with Knex
Knex's seed system mirrors its migrations. Create a seed file, and Knex drops it into the seeds directory from your knexfile.js.
npx knex seed:make 01_reference_data
npx knex seed:make 02_dev_users
# Run all seeds (alphabetical order — hence the number prefixes)
npx knex seed:run
# Run just one
npx knex seed:run --specific=01_reference_data.js
Ordering matters
Knex runs seeds in alphabetical order, so number your files to control dependencies — seed the tables that others reference first:
01_reference_data.js— roles, categories, statuses02_users.js— needs roles to exist03_products.js— needs categories and users04_orders.js— needs users and products
Reference data seed
// db/seeds/01_reference_data.js
// Reference data: runs in EVERY environment, including production.
exports.seed = async function (knex) {
await knex('order_statuses')
.insert([
{ id: 1, code: 'pending', name: 'Pending' },
{ id: 2, code: 'shipped', name: 'Shipped' },
{ id: 3, code: 'delivered', name: 'Delivered' },
{ id: 4, code: 'cancelled', name: 'Cancelled' },
])
.onConflict('id')
.merge(); // idempotent, and safe for rows referenced by orders
};
Development seed with password hashing
Development data can be destructive (delete-then-insert) because it's disposable — but it still needs an environment guard and should hash passwords rather than storing them in plain text:
// db/seeds/02_dev_users.js
const bcrypt = require('bcrypt');
exports.seed = async function (knex) {
if (process.env.NODE_ENV === 'production') return; // guard
await knex('users').del();
const hash = (pw) => bcrypt.hash(pw, 10); // never store raw passwords
await knex('users').insert([
{
username: 'admin',
email: 'admin@example.com',
password_hash: await hash('admin123'),
is_admin: true,
},
{
username: 'demo',
email: 'demo@example.com',
password_hash: await hash('demo123'),
is_admin: false,
},
]);
};
💡 Declarative vs. programmatic seeds
A short list of known rows (like reference data) is a declarative seed — you spell out every record. When you need hundreds of varied rows, you switch to a programmatic seed that generates them in a loop. That's exactly where Faker.js comes in.
Realistic Data with Faker.js
Hand-typing fifty users is tedious and produces obviously fake data. Faker.js generates realistic names, emails, addresses, prices, and more — perfect for filling a development database with data that actually exercises your UI and business logic.
🎬 Analogy: cooking-show prep
Using Faker is like being the prep chef on a cooking show. Instead of sourcing every real ingredient, you assemble a realistic-looking kitchen fast. The camera (your app) sees a full, varied set of ingredients, but you built it in minutes. And like show food, it just needs to look right for development — it isn't production data.
npm install @faker-js/faker
// db/seeds/03_dev_products.js
const { faker } = require('@faker-js/faker');
exports.seed = async function (knex) {
if (process.env.NODE_ENV === 'production') return; // guard
await knex('products').del();
const products = [];
for (let i = 0; i < 100; i++) {
products.push({
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
price: parseFloat(faker.commerce.price({ min: 10, max: 1000 })),
category_id: faker.number.int({ min: 1, max: 5 }),
created_at: faker.date.past(),
});
}
// batchInsert splits a large array into chunks — far faster than
// 100 individual INSERTs, and avoids hitting parameter limits.
await knex.batchInsert('products', products, 25);
};
Generating related data
Realistic seeds respect relationships. To create orders, first read the real IDs that already exist, then reference them — never invent foreign keys and hope they line up:
// db/seeds/04_dev_orders.js
const { faker } = require('@faker-js/faker');
exports.seed = async function (knex) {
if (process.env.NODE_ENV === 'production') return;
await knex('orders').del();
// Pull IDs that actually exist so foreign keys are valid.
const users = await knex('users').select('id');
const products = await knex('products').select('id', 'price');
const orders = [];
for (let i = 0; i < 200; i++) {
const user = faker.helpers.arrayElement(users);
const picked = faker.helpers.arrayElements(
products,
faker.number.int({ min: 1, max: 5 })
);
const total = picked.reduce((sum, p) => sum + parseFloat(p.price), 0);
orders.push({
user_id: user.id,
status_id: faker.number.int({ min: 1, max: 4 }),
total_amount: parseFloat(total.toFixed(2)),
shipping_city: faker.location.city(),
created_at: faker.date.past(),
});
}
await knex.batchInsert('orders', orders, 50);
};
Handy Faker generators
| Category | Methods | Example output |
|---|---|---|
| Person | faker.person.firstName(), faker.person.fullName() | "Ada", "Grace Hopper" |
| Internet | faker.internet.email(), faker.internet.username() | "ada@example.com", "ada42" |
| Commerce | faker.commerce.productName(), faker.commerce.price() | "Ergonomic Keyboard", "129.99" |
| Location | faker.location.city(), faker.location.zipCode() | "New York", "10001" |
| Date | faker.date.past(), faker.date.recent() | Date objects |
⚠️ Batch, don't drip
Inserting 10,000 rows one INSERT at a time can take minutes and hammer the database. knex.batchInsert(table, rows, chunkSize) groups them into a handful of multi-row statements — often a 10–50× speedup. Reach for it whenever a seed generates more than a few dozen rows.
Seeding in CI/CD
Seeding shines when it's automated. In CI, a fresh database is migrated and seeded with predictable test data before the suite runs; in production, only reference data is seeded, once.
Per-environment npm scripts
// package.json
{
"scripts": {
"migrate:test": "knex migrate:latest --env test",
"seed:test": "knex seed:run --env test",
"setup:test": "npm run migrate:test && npm run seed:test",
"seed:dev": "knex seed:run --env development",
"seed:prod": "knex seed:run --env production --specific=01_reference_data.js"
}
}
Note the production script: it runs only the reference-data seed via --specific, never the development or Faker seeds. That's the environment guard reinforced at the command level.
A GitHub Actions test job
# .github/workflows/test.yml (excerpt)
- name: Run migrations
run: npm run migrate:test
- name: Seed test database
run: npm run seed:test
- name: Run tests
run: npm test
env:
NODE_ENV: test
✅ Reset vs. incremental
In dev and test, a full reset (rollback all, migrate, seed) guarantees a clean, known state — ideal before a test run. In production you never reset; you apply migrations and, at most, upsert reference data incrementally. Match the strategy to the environment's tolerance for data loss.
Practice & Quiz
🏋️ Exercise 1: Make a seed idempotent
Goal: This reference-data seed crashes with a unique-constraint error the second time it runs, because it only inserts. Rewrite it to be idempotent without deleting the rows (other tables reference these categories).
exports.seed = async function (knex) {
await knex('categories').insert([
{ id: 1, name: 'Electronics', slug: 'electronics' },
{ id: 2, name: 'Books', slug: 'books' },
]);
};
💡 Hint
You can't safely del() rows that orders/products point to. Use an upsert: .onConflict('id').merge() so an existing row is updated instead of causing an error.
✅ Solution
exports.seed = async function (knex) {
await knex('categories')
.insert([
{ id: 1, name: 'Electronics', slug: 'electronics' },
{ id: 2, name: 'Books', slug: 'books' },
])
.onConflict('id') // row already there?
.merge(); // update it — no duplicate, no crash, no deletion
};
🏋️ Exercise 2: Generate 50 guarded, batched users
Goal: Write a development seed that generates 50 realistic users with Faker, refuses to run in production, and inserts them efficiently.
💡 Hint
Start with the NODE_ENV === 'production' guard, build an array in a loop with faker.person / faker.internet, and finish with knex.batchInsert.
✅ Solution
const { faker } = require('@faker-js/faker');
exports.seed = async function (knex) {
if (process.env.NODE_ENV === 'production') return;
await knex('users').del();
const users = [];
for (let i = 0; i < 50; i++) {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
users.push({
username: faker.internet.username({ firstName, lastName }),
email: faker.internet.email({ firstName, lastName }),
password_hash: 'seed-placeholder-hash', // replace with a real bcrypt hash
is_admin: false,
});
}
await knex.batchInsert('users', users, 25);
};
🎯 Quick Quiz
Question 1: What does it mean for a seed to be "idempotent"?
Question 2: Which type of seed data should run in production?
Question 3: Why prefix Knex seed files with numbers like 01_, 02_?
Best Practices & Pitfalls
✅ Do
- Keep seed files in version control alongside your migrations
- Make every seed idempotent (delete-then-insert for disposable data, upsert for referenced data)
- Guard development/test seeds with a
NODE_ENVcheck - Separate reference seeds from development/test seeds into different files
- Order seed files (number prefixes) so foreign-key dependencies are satisfied
- Use
batchInsertfor large volumes; parameterize the count so you can generate more or less - Read existing IDs before referencing them in generated data
❌ Don't
- Never run development or Faker seeds against production
- Don't hard-code IDs that might collide across environments (except stable reference rows)
- Don't store plain-text passwords, even in seeds — hash them
- Don't insert thousands of rows one at a time
- Don't seed data whose foreign keys point at rows that don't exist yet
- Don't over-seed — bloated development data slows everyone down
🍽️ Analogy: opening night at a restaurant
Reference-data seeding is stocking the essentials every restaurant needs — plates, glasses, staple ingredients. Development seeding is preparing sample dishes for the staff to practice on. Test seeding sets up controlled scenarios to check each station. And in production you want only the essential ingredients ready — no random practice dishes sitting in the kitchen when real customers arrive.
Summary
🎉 Key Takeaways
- Seeding fills a migrated schema with data; the four kinds are reference, development, test, and demo
- Only reference data belongs in production — guard everything else with
NODE_ENV - Seeds must be idempotent: delete-then-insert for disposable data, upsert (
onConflict().merge()) for referenced data - Number Knex seed files to control order, and seed referenced tables first
- Faker.js +
batchInsertgenerate large volumes of realistic data quickly
📚 Additional Resources
- Knex.js — Seed files documentation
- Faker.js — official guide
- Prisma — database seeding
- Martin Fowler — Test Data Builder pattern
🚀 What's Next?
You've now covered the full database lifecycle — connect, model, query, relate, migrate, and seed. Time to put it all together: the next lesson is a hands-on weekend project building a complete Blog API with database integration, migrations and seeds included.
🎉 You've completed the migrations & seeding track!
Migrate to build the schema, seed to fill it — safely, idempotently, and per environment. That's exactly how professional teams manage their databases.