RizTech Academy logo
RizTech Academy
The Data LayerLesson 6 of 625 min

Seeding a database you can actually develop against

An empty database is useless for development. Three products with the names "test", "test2" and "asdf" are barely better — you cannot see whether the grid works, whether long names break the layout, or whether the out-of-stock state looks right.

Good seed data is a development tool, and it takes half an hour once.

What seed data is for

Developing against something realistic. A grid of forty products with real names and mixed stock shows you problems that three test rows hide.

Starting from a known state. Tests and demos need the same data every time.

Onboarding. A new developer clones, seeds, and has a working shop — rather than clicking through an admin screen for an hour.

Setting it up

// apps/api/package.json
{
  "prisma": {
    "seed": "ts-node --transpile-only prisma/seed.ts"
  }
}
npm install -D ts-node
npx prisma db seed

prisma migrate reset also runs the seed automatically, which makes "start clean" one command.

A seed that is worth having

// apps/api/prisma/seed.ts
import { PrismaClient, Unit } from "@prisma/client";

const prisma = new PrismaClient();

const CATEGORIES = [
  { slug: "staples", name: "Staples & Grains", sortOrder: 1 },
  { slug: "vegetables", name: "Fresh Vegetables", sortOrder: 2 },
  { slug: "dairy", name: "Dairy & Eggs", sortOrder: 3 },
  { slug: "snacks", name: "Snacks & Beverages", sortOrder: 4 },
  { slug: "household", name: "Household", sortOrder: 5 },
];

const PRODUCTS = [
  {
    slug: "aashirvaad-select-atta",
    name: "Aashirvaad Select Sharbati Atta",
    brand: "Aashirvaad",
    category: "staples",
    description: "Stone-ground whole wheat flour from Sharbati wheat.",
    variants: [
      { sku: "AAS-ATTA-5", label: "5 kg", unit: Unit.KILOGRAM, quantity: 5, pricePaise: 28500, mrpPaise: 31000, stock: 12 },
      { sku: "AAS-ATTA-10", label: "10 kg", unit: Unit.KILOGRAM, quantity: 10, pricePaise: 56000, mrpPaise: 62000, stock: 4 },
    ],
  },
  {
    slug: "tata-salt",
    name: "Tata Salt Iodised",
    brand: "Tata",
    category: "staples",
    variants: [
      { sku: "TATA-SALT-1", label: "1 kg", unit: Unit.KILOGRAM, quantity: 1, pricePaise: 2800, stock: 0 },
    ],
  },
  {
    slug: "tomato",
    name: "Tomato",
    category: "vegetables",
    variants: [
      { sku: "VEG-TOM-500", label: "500 g", unit: Unit.GRAM, quantity: 0.5, pricePaise: 2500, stock: 40 },
      { sku: "VEG-TOM-1000", label: "1 kg", unit: Unit.KILOGRAM, quantity: 1, pricePaise: 4800, stock: 25 },
    ],
  },
  {
    slug: "amul-taaza-toned-milk-tetra-pak-long-name-for-testing-layout",
    name: "Amul Taaza Homogenised Toned Milk Tetra Pak Long Life",
    brand: "Amul",
    category: "dairy",
    variants: [
      { sku: "AMUL-MILK-1L", label: "1 L", unit: Unit.LITRE, quantity: 1, pricePaise: 7500, stock: 30 },
    ],
  },
];

async function main() {
  console.log("Seeding…");

  // Order matters: children first, because of foreign keys.
  await prisma.variant.deleteMany();
  await prisma.product.deleteMany();
  await prisma.category.deleteMany();

  for (const category of CATEGORIES) {
    await prisma.category.create({ data: category });
  }

  for (const product of PRODUCTS) {
    const { category, variants, ...rest } = product;
    await prisma.product.create({
      data: {
        ...rest,
        category: { connect: { slug: category } },
        variants: { create: variants },
      },
    });
  }

  const counts = {
    categories: await prisma.category.count(),
    products: await prisma.product.count(),
    variants: await prisma.variant.count(),
  };
  console.log("Seeded:", counts);
}

main()
  .catch((error) => {
    console.error(error);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

What makes this data useful

Each of these is deliberate.

Real names. "Aashirvaad Select Sharbati Atta" tells you whether the card handles a long name. "Product 1" does not.

A deliberately very long name. The Amul entry exists to break layouts on purpose, so you find the problem before a customer does.

Zero stock on Tata Salt. The out-of-stock state gets exercised every time somebody loads the shop, so it cannot rot.

MRP on some products and not others, so both the discounted and plain price displays appear.

Products with one variant and with two, because the variant selector must handle both.

Realistic prices in paise. ₹285.00 is 28500. Seeing the conversion in seed data reinforces the convention.

process.exit(1) on failure, so a broken seed fails your build instead of passing silently.

Idempotent seeding

The version above deletes everything first, which is right for development.

When you want to run it repeatedly without losing data, upsert:

for (const category of CATEGORIES) {
  await prisma.category.upsert({
    where: { slug: category.slug },
    update: category,
    create: category,
  });
}

Runs safely any number of times. Useful for reference data — categories, delivery slots, settings — that also needs to exist in production.

Do not seed products into production. Reference data yes; sample content no.

Separate data for tests

// apps/api/prisma/seed-test.ts
export async function seedMinimal(prisma: PrismaClient) {
  const category = await prisma.category.create({
    data: { slug: "test", name: "Test" },
  });

  return prisma.product.create({
    data: {
      slug: "test-product",
      name: "Test Product",
      categoryId: category.id,
      variants: {
        create: [{ sku: "TEST-1", label: "1 kg", unit: Unit.KILOGRAM, quantity: 1, pricePaise: 10000, stock: 5 }],
      },
    },
    include: { variants: true },
  });
}

Tests want the minimum, not the full catalogue. Forty products make tests slower and their assertions harder to read. A test asserting "the total is ₹200" is clearer when the fixture has two items.

Generating volume

To test pagination and performance you need more than forty rows:

import { faker } from "@faker-js/faker";

async function seedMany(count: number) {
  const category = await prisma.category.findFirstOrThrow();

  for (let i = 0; i < count; i++) {
    await prisma.product.create({
      data: {
        slug: `generated-${i}`,
        name: faker.commerce.productName(),
        categoryId: category.id,
        variants: {
          create: [{
            sku: `GEN-${i}`,
            label: "1 kg",
            unit: Unit.KILOGRAM,
            quantity: 1,
            pricePaise: faker.number.int({ min: 1000, max: 100000 }),
            stock: faker.number.int({ min: 0, max: 50 }),
          }],
        },
      },
    });
  }
}

Keep generated data separate from curated data. The forty hand-written products are what you develop against; a thousand generated ones are for testing pagination and query performance, and their names are nonsense.

Note this loop is one query per product. For thousands, createMany is far faster — a reasonable place to notice the difference.

Resetting

npx prisma migrate reset

Drops the database, re-runs every migration, runs the seed. One command to a known-good state.

Make this habitual. Being willing to throw the database away is what keeps you from accumulating a local state nobody else can reproduce — the same argument as recreating a virtual environment.

Add it to your README:

docker compose up -d
npx prisma migrate reset
npm run dev

Three commands from clone to running shop.

Check your work

Why seed data should use real product names: to expose layout problems that "test1" hides.

Why include a deliberately long name: so the grid's clamping and overflow handling are exercised every time.

Why one product has zero stock: so the out-of-stock state is visible constantly and cannot rot.

Delete order in a reset: children before parents — variants, products, then categories — because of foreign keys.

When to use upsert: for reference data that must exist and should not be duplicated, including in production.

Why tests get their own minimal fixture: fewer rows means faster tests and assertions you can read.

Why generated data stays separate from curated data: you develop against the realistic set; the generated bulk is only for pagination and performance.

What migrate reset does: drops the database, re-runs migrations, and re-seeds.

Practice

  1. Write the seed file and run it. Confirm the counts.
  2. Load your product grid and check the long name does not break it. If it does, fix the layout.
  3. Confirm the zero-stock product shows the out-of-stock state.
  4. Add a product with an MRP and one without. Check both price displays.
  5. Run the seed twice. Confirm you do not get duplicates.
  6. Convert the categories to upsert and run it against a database that already has them.
  7. Generate a thousand products with faker. Test pagination with them.
  8. Time the faker loop, then rewrite it with createMany and compare.
  9. Run migrate reset and confirm you are back to a clean seeded state.
  10. Write the three-command setup into your README and follow it from a fresh clone.

That is module six. You can model a real catalogue, query it with filters and pagination, protect writes that must not half-succeed, and develop against data worth looking at.

Next module: the API surface itself.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship