Database Seeding skill for Codex

Rerunnable seeds that look like production. Codex separates reference data, demo data, test fixtures and production copies before writing a line, then builds seeds that upsert on natural keys, refuse to run against production hosts, and model real skew and edge cases instead of uniform noise. For staging copies it masks deterministically to keep joins intact and runs a bundled PII scanner over the output before it leaves.

  • Databases
  • $database-seeding
  • 204 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- database-seeding

Installs to ~/.agents/skills/database-seeding/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $database-seeding, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds repeatable seed data for development, demos and staging - idempotent seed scripts for Prisma, Drizzle, Rails, Django and Laravel, deterministic Faker data with realistic distributions, bulk loads with COPY, and masked or subsetted copies of production that keep referential integrity. Ships a stdlib PII scanner for dumps and CSVs. Use when asked to write or fix a seed script, populate a local database with believable data, make db:seed safe to rerun, or produce an anonymized staging copy from production.

Use it when

  • A new developer needs a working local dataset with one command
  • Seeds break on rerun, run in the wrong environment or take minutes
  • Demos or performance work need believable volume and skew
  • Staging needs a masked or subsetted copy of production

Use something else when

How to check the result

Run the seed twice on an empty database and confirm identical row counts, log in as each persona, and for masked copies get a clean exit from scripts/scan_pii.py.

The mistake it prevents

Putting reference rows the app needs (default plans, system users) in dev-only seeds, so production boots without them.

~/.agents/skills/database-seeding/SKILL.md

--- name: database-seeding description: Builds repeatable seed data for development, demos and staging - idempotent seed scripts for Prisma, Drizzle, Rails, Django and Laravel,… license: MIT ---

Done well means: one command rebuilds a working dataset from nothing, running it twice changes nothing, the data looks like production in shape (volumes, skew, edge cases) without containing a single real person, and seeds never run against production by accident.

Classify what is being seeded

Different data has different homes. Mixing them is the root of most seed bugs.

KindExampleWhere it livesRuns in prod?
Reference dataCountries, plan tiers, permission namesMigration or idempotent reference seedYes
Dev/demo dataUsers, orgs, orders for local work and screenshotsSeed scriptNever
Test fixturesRows a single test needsFactories inside the test suite ($test-data-factories)Never
Production-like copyMasked subset for staging or performance workMasking pipeline, separate jobNever (source only)

If application code breaks without a row (a default plan, a system user), it is reference data: put it in a migration or a reference seed that production runs, not in dev seeds.

Workflow

- [ ] 1. Read AGENTS.md, the ORM config and existing seed files; find the seed command
- [ ] 2. List entities in dependency order (parents before children)
- [ ] 3. Write an environment guard that refuses production
- [ ] 4. Seed reference data with upserts on natural keys
- [ ] 5. Generate demo data deterministically (fixed Faker seed)
- [ ] 6. Run twice on an empty DB; second run must be a no-op or a clean reset
- [ ] 7. Verify counts, constraints and app smoke test

Hard guard first

Every seed entry point starts with a check that cannot be skipped by a missing env var:

const url = new URL(process.env.DATABASE_URL ?? "");
const allowed = ["localhost", "127.0.0.1", "db"]; // docker compose service name
if (process.env.NODE_ENV === "production" || !allowed.includes(url.hostname)) {
  throw new Error(`Refusing to seed ${url.hostname}; set SEED_ALLOW_HOST explicitly`);
}

Destructive resets (TRUNCATE ... CASCADE, prisma migrate reset, rails db:reset, manage.py flush) wipe data. Run them only on a local or disposable database, and ask the user before running one against anything shared such as staging.

Idempotent seeds by framework

Upsert on a natural or fixed key, never on auto-increment ids.

// Prisma: prisma/seed.ts (seed command set in package.json "prisma.seed" or prisma.config.ts)
import { PrismaClient } from "@prisma/client";
import { faker } from "@faker-js/faker";
const prisma = new PrismaClient();
faker.seed(20251028);

for (const tier of ["free", "pro", "enterprise"]) {
  await prisma.plan.upsert({ where: { code: tier }, update: {}, create: { code: tier } });
}
const users = Array.from({ length: 500 }, (_, i) => ({
  email: `user${i}@example.test`,          // reserved TLD, never a real inbox
  name: faker.person.fullName(),
  createdAt: faker.date.past({ years: 2 }),
}));
await prisma.user.createMany({ data: users, skipDuplicates: true });
// Drizzle (Postgres)
await db.insert(plans).values(rows).onConflictDoNothing({ target: plans.code });
# Rails: db/seeds.rb, run with bin/rails db:seed
Faker::Config.random = Random.new(42)
%w[free pro enterprise].each { |code| Plan.find_or_create_by!(code:) }
User.upsert_all(users, unique_by: :email)   # one statement; skips validations and callbacks
# Django: a management command, e.g. app/management/commands/seed.py
from faker import Faker
Faker.seed(42)
Plan.objects.update_or_create(code="pro", defaults={"price_cents": 2900})
User.objects.bulk_create(users, batch_size=1000, ignore_conflicts=True)

Laravel: php artisan db:seed --class=DemoSeeder, using model factories with fake()->seed(42) set in the seeder, and upsert() for reference rows.

Prefer bulk calls (createMany, insert_all/upsert_all, bulk_create) over per-row inserts: 50k rows one by one through an ORM takes minutes; in bulk it takes seconds.

Make it realistic

Uniform random data hides the bugs real data triggers. Encode the shape:

  • Skew. Most customers have a few orders; a few have thousands. Draw counts from a long-tailed distribution (random.paretovariate, or pick 2% of users as heavy users).
  • Time. Spread created_at over months with weekday and business-hour bias so date filters, partitions and charts behave.
  • Edge cases on purpose. Names with apostrophes and non-Latin scripts, emoji, 255-char strings, NULLs in optional columns, soft-deleted rows, zero-quantity orders, users in several time zones.
  • Personas. A handful of fixed, documented accounts ([email protected], [email protected]) that developers and screenshots rely on.
  • Volume tiers. SEED_SCALE=small|large: small for daily dev (seconds), large for pagination and performance work.

Use example.test, example.com or .invalid addresses and 555-01xx style phone numbers so nothing can email or text a real person.

Bulk volume in Postgres

For hundreds of thousands of rows, generate in SQL or stream with COPY:

INSERT INTO events (user_id, kind, created_at)
SELECT (random() * 49999)::int + 1,
       (ARRAY['view','click','purchase'])[1 + floor(random() * 3)::int],
       now() - random() * interval '180 days'
FROM generate_series(1, 2000000);
psql "$DATABASE_URL" -c "\copy users(email,name,created_at) FROM 'seed/users.csv' CSV HEADER"

Run ANALYZE afterwards so the planner sees the new volume.

Masked copies of production

Copying production data is a privacy decision, not just an engineering one. Before starting, confirm with the user that the copy is approved, where it will live, and who can access it. Read $gdpr-privacy-engineering when regulated data is involved.

ApproachToolingUse when
Static masking at dump timeGreenmask, PostgreSQL Anonymizer (anon extension)Regular staging refresh
Subset then maskCustom SQL that follows FKs from a sample of root rowsProd is too big to copy
Synthetic onlySeed scripts aboveData must never leave prod

Rules for masking:

  • Mask deterministically (keyed hash of the original) wherever a value is joined or deduplicated on, so the same email maps to the same fake email in every table.
  • Null out or replace secrets entirely: password hashes, API keys, OAuth tokens, session ids, webhook secrets, TOTP seeds.
  • Free-text columns (notes, support messages, addresses in JSON) leak the most PII; replace them wholesale rather than regex-scrubbing.
  • Keep distributions and NULL rates; shift dates by a consistent random offset per entity.
  • Run the masking inside the production boundary; only masked output leaves it.

PII scan before anything leaves

python3 scripts/scan_pii.py dump.sql
python3 scripts/scan_pii.py exports/ --allow-domain example.test --max-samples 5

It scans SQL dumps, CSV and text files for email addresses outside the allowed domains, phone-like numbers, card numbers that pass the Luhn check, US SSN patterns, IBAN-like strings and JWT or API-key-shaped tokens. It prints counts per file and category with masked samples and exits 1 when anything is found. Treat a clean scan as necessary, not sufficient: it cannot see names or free-text addresses.

Verification

  • Run the seed twice on a fresh database; row counts identical after both runs.
  • SELECT count(*) per table matches the intended scale tier.
  • Constraint check: no orphan rows (LEFT JOIN parent ... WHERE parent.id IS NULL).
  • App smoke test: log in as each persona, open the busiest pages.
  • Masked copies: scan_pii.py exits 0 and a sample review finds no real names.

Gotchas

  • Sequences: inserting explicit ids leaves the sequence behind; fix with SELECT setval(pg_get_serial_sequence('users','id'), max(id)) FROM users;.
  • upsert_all, bulk_create and createMany skip model callbacks and validations; derived columns (slugs, counters, search vectors) must be set explicitly.
  • faker.seed only fixes output while the call order is stable; adding a field shifts everything after it. Seed per entity (faker.seed(base + i)) for stable personas.
  • Seeds that call external services (Stripe, email) must use stubs or test keys.
  • Unscoped Faker unique generators exhaust on large volumes; build uniqueness from the loop index instead.

Output template

**Command:** pnpm db:seed (SEED_SCALE=small, about 6 s; large about 90 s)
**Guard:** refuses non-local hosts and NODE_ENV=production
**Data:** 3 plans (reference), 500 users, 12 orgs, 18k orders (Pareto skew), 6 personas
**Idempotency:** second run inserts 0 rows (verified)
**Masked copy:** n/a | Greenmask config at ops/greenmask.yml, scan_pii.py clean

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Database Seeding"
  short_description: "Rerunnable seeds that look like production"
  default_prompt: "$database-seeding Write a seed script that fills my local Postgres with realistic users, orgs and orders"
scripts/scan_pii.py133 lines, 5.0 KB
#!/usr/bin/env python3
"""Scan SQL dumps, CSV and text files for data that looks like real PII or secrets.

Usage:
  python3 scan_pii.py PATH [PATH ...] [--allow-domain example.test] [--max-samples 3]

PATH may be a file or a directory (scanned recursively for .sql, .csv, .tsv, .txt,
.json, .jsonl, .ndjson). Exits 1 if anything is found, 0 if clean, 2 on usage errors.
Samples are partially masked so the report itself does not leak data.
"""
from __future__ import annotations

import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path

EXTENSIONS = {".sql", ".csv", ".tsv", ".txt", ".json", ".jsonl", ".ndjson"}
DEFAULT_ALLOWED = {"example.com", "example.org", "example.net", "example.test", "test", "invalid", "localhost"}

PATTERNS = {
    "email": re.compile(r"\b[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)\b"),
    "card": re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)"),
    "us_ssn": re.compile(r"(?<!\d)(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}(?!\d)"),
    "iban": re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"),
    "phone": re.compile(r"(?<![\w.-])\+?\d{1,3}[ .-]?\(?\d{2,4}\)?[ .-]?\d{3,4}[ .-]?\d{3,4}(?![\w-])"),
    "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
    "api_key": re.compile(r"\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bAKIA[0-9A-Z]{16}\b|\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bxox[abpr]-[A-Za-z0-9-]{10,}\b"),
}


def luhn_ok(digits: str) -> bool:
    total, parity = 0, len(digits) % 2
    for i, ch in enumerate(digits):
        d = int(ch)
        if i % 2 == parity:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0


def mask(value: str) -> str:
    if len(value) <= 6:
        return value[0] + "*" * (len(value) - 1)
    return value[:3] + "*" * (len(value) - 5) + value[-2:]


def domain_allowed(domain: str, allowed: set[str]) -> bool:
    domain = domain.lower()
    return any(domain == a or domain.endswith("." + a) for a in allowed)


def scan_line(line: str, allowed: set[str]):
    taken: list[tuple[int, int]] = []  # spans already reported, so a card is not also a phone
    for kind, rx in PATTERNS.items():
        for m in rx.finditer(line):
            value = m.group(0)
            if any(m.start() < end and start < m.end() for start, end in taken):
                continue
            if kind == "email" and domain_allowed(m.group(1), allowed):
                continue
            if kind == "card":
                digits = re.sub(r"\D", "", value)
                if not (13 <= len(digits) <= 19) or len(set(digits)) == 1 or not luhn_ok(digits):
                    continue
            if kind == "phone":
                digits = re.sub(r"\D", "", value)
                # Skip obvious non-phones: dates, plain ids, fictional 555-01xx numbers.
                if len(digits) < 9 or re.fullmatch(r"(19|20)\d{6,}", digits) or "55501" in digits:
                    continue
                if not re.search(r"[ .()+-]", value):
                    continue
            taken.append(m.span())
            yield kind, value


def iter_files(paths: list[str]):
    for p in paths:
        path = Path(p)
        if path.is_dir():
            for f in sorted(path.rglob("*")):
                if f.is_file() and f.suffix.lower() in EXTENSIONS:
                    yield f
        elif path.is_file():
            yield path
        else:
            print(f"error: {p} does not exist", file=sys.stderr)
            sys.exit(2)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("paths", nargs="+")
    ap.add_argument("--allow-domain", action="append", default=[], help="email domain treated as fake (repeatable)")
    ap.add_argument("--max-samples", type=int, default=3)
    args = ap.parse_args()

    allowed = DEFAULT_ALLOWED | {d.lower().lstrip("@") for d in args.allow_domain}
    findings: dict[Path, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
    counts: dict[Path, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    files = 0

    for f in iter_files(args.paths):
        files += 1
        with f.open("r", encoding="utf-8", errors="replace") as fh:
            for lineno, line in enumerate(fh, start=1):
                for kind, value in scan_line(line, allowed):
                    counts[f][kind] += 1
                    if len(findings[f][kind]) < args.max_samples:
                        findings[f][kind].append(f"line {lineno}: {mask(value)}")

    if not counts:
        print(f"clean: scanned {files} file(s), no PII-like values found")
        return 0

    total = 0
    for f in sorted(counts):
        print(f"\n{f}")
        for kind in sorted(counts[f]):
            n = counts[f][kind]
            total += n
            print(f"  {kind:8} {n:>8}")
            for s in findings[f][kind]:
                print(f"           {s}")
    print(f"\nFOUND {total} PII-like value(s) in {len(counts)} of {files} file(s)")
    return 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc