Skip to main content

🌱 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.

graph LR A[Empty migrated DB] --> B[Run seeds] B --> C[Reference data] B --> D[Development data] B --> E[Test data] C --> F[Usable dev database] D --> F E --> F

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.

TypePurposeWhere it runs
Reference dataEssential, stable lookup values the app needs to function (countries, roles, statuses)Every environment, including production
Development dataRich, realistic content so developers can work locally (sample users, products, orders)Development only
Test dataConsistent, predictable records covering edge cases for automated testsTest/CI only
Demo dataPolished, curated data that showcases the productStaging/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.
Different environments receive different subsets of seed data; only reference data reaches production Development Reference + Development + lots of Faker Test / CI Reference + fixed Test data predictable Production Reference ONLY 🔒 no test data
Seed data narrows as it approaches production. The one rule that never bends: production gets reference data and nothing else.

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, statuses
  • 02_users.js — needs roles to exist
  • 03_products.js — needs categories and users
  • 04_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

CategoryMethodsExample output
Personfaker.person.firstName(), faker.person.fullName()"Ada", "Grace Hopper"
Internetfaker.internet.email(), faker.internet.username()"ada@example.com", "ada42"
Commercefaker.commerce.productName(), faker.commerce.price()"Ergonomic Keyboard", "129.99"
Locationfaker.location.city(), faker.location.zipCode()"New York", "10001"
Datefaker.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
flowchart LR A[CI starts] --> B[Migrate fresh DB] B --> C[Seed test data] C --> D[Run tests] D --> E[Deploy to prod] E --> F[Migrate prod] F --> G[Seed reference data ONLY]

✅ 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_ENV check
  • Separate reference seeds from development/test seeds into different files
  • Order seed files (number prefixes) so foreign-key dependencies are satisfied
  • Use batchInsert for 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 + batchInsert generate large volumes of realistic data quickly

📚 Additional Resources

🚀 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.