Small Data, Big Lies: 6 Bugs Your Test Suite Will Never Catch
By Mikhail Shytsko, Founder at Seedfast · · Updated
A green test suite with 94% coverage carried this PR into production unquestioned, and two hours later the on-call phone lit up — a 47-second orders page, an export endpoint OOM-killing its pods, pagination skipping page 7. Between the suite and production the only real difference was the data, 2 million orders in one and 12 in the other. That gap is the data volume blind spot, and almost every team has one.
- Six bug classes stay invisible at 10 rows and become incidents at production scale: pagination off-by-one, N+1 queries, missing indexes, memory blowups, timeout cascades, and unique constraint collisions.
- All six carry one signature, where passing tests hide a cost that climbs with row count until the bug surfaces in production.
- The fix is to run the suite against realistic volumes before you deploy, turning each production incident into a failing test.
- Seedfast populates your database with production-scale, foreign-key-valid rows, so your existing suite runs against realistic counts.
Most test databases hold between 5 and 50 rows per table, fine for unit tests but enough to plant a dangerous assumption — that code passing at 10 rows will pass at 10 million. Small data hides whole categories of bugs that turn catastrophic at scale, and the sharpest version shows up when an AI agent leaves the tables empty rather than merely thin. Here are the six that bite most often.
This is the one everybody has shipped at least once. A paginated API looks correct while one page of 10 rows covers the whole table. Push it to 10,001 rows and page 1001 comes back empty or duplicates page 1000, depending on whether the offset math uses > or >=.
-- Looks correct with small data
SELECT * FROM orders ORDER BY created_at LIMIT 10 OFFSET ?
-- At scale: duplicate rows when created_at isn't unique
-- Rows shift between pages during concurrent inserts
The fix is usually cursor-based (keyset) pagination, but the bug hides until you have enough rows to fill several pages and timestamps dense enough to collide. The same holds in load testing with an empty database, where the plan you measure at 50 rows is nothing like production's.
seedfast seed --scope "seed 10,000 orders with timestamps"
Then page through to the end and compare the item tally against SELECT COUNT(*); a mismatch is the bug.
An ORM loads a list of orders, and because each one lazily fetches its customer in a separate round-trip, the query count tracks the row count. A few orders cost a few queries nobody notices; a few thousand become a few thousand queries, and latency follows.
# 5 rows: 6 queries, 80ms
GET /api/orders → 200 OK (80ms)
# 5,000 rows: 5,001 queries, 12,400ms
GET /api/orders → 200 OK (12,400ms) # or timeout
seedfast seed --scope "seed 5,000 orders with customers and line items"
Enable query logging and count; any list view firing more than ~10 queries has a problem.
Without an index, PostgreSQL scans 100 rows in well under a millisecond, so at development scale the query feels instant. The million-row version of that same scan runs for whole seconds, because the planner has no usable index and falls back to reading every row.
-- Fast at 100 rows (seq scan is fine)
SELECT * FROM users WHERE email = 'john@example.com';
-- 1M rows: 800ms full table scan
-- With index: 0.1ms
seedfast seed --scope "seed 100,000 users with realistic email addresses"
Then run EXPLAIN ANALYZE on your critical queries; any sequential scan on a table over 10K rows is a red flag. The PostgreSQL test data cookbook has the SQL patterns for generating those volumes by hand.
Loading a whole result set into memory costs a few kilobytes at 100 rows and sails through every test, right up until the same endpoint meets 100,000 rows in production, allocates 500MB, and gets OOM-killed mid-request.
// Loads ALL rows into memory
rows, _ := db.Query("SELECT id, email FROM users")
defer rows.Close()
var allUsers []User
for rows.Next() {
var u User
rows.Scan(&u.ID, &u.Email)
allUsers = append(allUsers, u) // grows unbounded
}
The same shape hides in export endpoints, report generators, batch jobs, and admin dashboards, anywhere code buffers a whole result set into an unbounded collection, harmless until production hands it enough rows to blow the heap.
seedfast seed --scope "seed 100,000 users with profiles and activity logs"
Hit your export and report endpoints and watch memory; RSS climbing with row count means an unbounded query buffering the whole set.
Service A calls Service B, which queries the database; while the tables stay small the query returns in 5ms. Once the tables have grown, the same query needs three seconds, blowing past Service A's two-second timeout, so the retry piles onto an already-busy Service B, and the circuit breaker trips and the dashboard goes red.
Small data: A → B (50ms) → DB (5ms) ✓
Large data: A → B (3.2s) → DB (2.8s) ✗ timeout
A → B (retry) → DB ✗ timeout (DB now under double load)
A → circuit breaker open ✗ cascade failure
A cascade like this needs volume, roughly 500,000 rows before one query is slow enough to breach a timeout and start the chain.
seedfast seed --scope "seed 500,000 transactions with accounts and categories"
Run your integration suite and watch for requests nearing your timeout thresholds; one at 80% today is a timeout tomorrow as the table grows.
Test fixtures lean on hand-picked values like user1@test.com and user2@test.com, which never collide because a person chose each one. Real signups don't cooperate, producing duplicates at scale when two users register the same normalized address, or when a batch import slips in near-duplicates that pass validation alone but violate the constraint together.
-- Works with 10 hand-crafted rows
INSERT INTO users (email) VALUES ('user1@test.com');
-- Fails at 10,000 rows with realistic data distributions
-- ERROR: duplicate key value violates unique constraint "users_email_key"
Generated data spreads across a realistic distribution, so it includes the near-collisions a hand-written fixture set never thinks to add. Ask Seedfast for 10,000 emails and your unique constraints get exercised against genuinely varied input, all while foreign key relationships stay valid.
seedfast seed --scope "seed 10,000 users with realistic names and emails"
A collision that surfaces under 10,000 realistic rows is one production never gets to spring on you, and volume closer to production scale flushes out more of these edge cases early.
Line the six up and the same four-part fingerprint appears every time:
- Invisible at small scale — test suite passes, code review looks fine
- Proportional to data volume — gets worse as tables grow
- Discovered in production — where the data is, and the users are
- Expensive to fix after the fact — incident response, hotfixes, post-mortems
The remedy never changes either, which is to test against realistic data volumes before deploying.
None of this needs a production copy, fixture factories hand-written for 50 tables, or SQL dumps that drift from the schema.
# Seed production-scale data in your dev/staging database
seedfast seed --scope "seed 100,000 users with orders, payments, and activity logs"
Point it at your database and it works out the right proportions across related tables, holding every foreign key valid. You describe the scope in plain English, review the plan, and approve:
Seeding Plan:
public.users — 100,000 records
public.orders — 450,000 records
public.payments — 320,000 records
public.order_items — 1,200,000 records
public.activity_logs — 2,000,000 records
Total: 4,070,000 records across 5 tables
Approve? (Y/n)
Getting started is a 30-day free trial, no card required; if a scope runs past your plan limits, the CLI asks you to trim it in the terminal. It writes only to the tables in your scope and never touches the rows already there.
Add a seeding step to your pipeline so the test suite runs against real data volumes on every PR:
- name: Run migrations
run: npm run migrate
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Seed test database
run: seedfast seed --scope "seed 50,000 users with orders" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Run tests
run: npm test
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
With SEEDFAST_API_KEY set, the CLI runs non-interactively, so nothing pauses for a prompt in CI. Because a run only ever adds rows and never rewrites what a table already holds, repeating the same scope keeps growing the data. A clean repeat means starting empty, from a throwaway ephemeral database or freshly truncated target tables. The CI/CD database seeding guide covers the full pipeline, ephemeral per-PR databases included.
You don't have to jump to a million rows. Start with enough to surface the first category of bugs:
| Goal | Suggested scope | What it catches |
|---|---|---|
| Pagination bugs | 1,000+ rows in paginated tables | Off-by-one, cursor issues |
| N+1 queries | 500+ rows with relationships | Lazy loading performance |
| Missing indexes | 50,000+ rows | Sequential scan bottlenecks |
| Memory issues | 100,000+ rows | Unbounded collection growth |
| Timeout cascades | 500,000+ rows | Cross-service timeout breaches |
After the first bug, you'll want every PR run against realistic volumes as a habit. In our internal runs, Seedfast generated ~1M FK-valid rows on a 20-table SaaS schema in about 3.5 minutes. For the largest volumes, the large-volume seeding guide covers batching and scope tuning.
Why do bugs only appear with large amounts of test data?
Because database behavior depends on how much data is in the tables, the same code takes different paths as it grows. The planner picks sequential scans on tiny tables and index scans on large ones, ORMs fan a relation into N+1 queries only once the list is long, and memory use tracks row count. At 10 rows none of that registers, so the defect waits until production supplies the volume that exposes it.
How much test data do I need to catch performance bugs?
Start at roughly 10× your current test volume and work up. Each bug class trips at its own threshold, all listed in the sizing table above. Watch for the point where latency stops scaling linearly with row count; that knee usually sits right on an architectural bottleneck.
What is the N+1 query problem?
It is the pattern where fetching a list of N records fires one follow-up query per record for a related row, so a single logical read becomes N+1 trips. Each query is fast, so the endpoint looks healthy until the list grows long, which is why thin test data hides it.
Can I just copy production data into my test database instead?
Copying production data drags PII and compliance exposure along with it. Under HIPAA, PCI-DSS, GDPR, or SOC 2, pulling real records into dev or CI collides with data-minimization and access-control rules, making it a compliance question before an engineering one. Exports also drift from the schema and break on the next migration. Generating from the schema gives you the same volumes with no real record in the pipeline. The schema shape itself (table and column names, types, constraints) does travel to an AI provider during generation, though row values never do, so teams whose naming alone is sensitive should weigh that path.
How does Seedfast generate production-scale test data?
You give Seedfast a plain-English scope and the volumes you want, and it produces connected relational rows with every foreign key valid on insert, then shows you the plan to approve first. Since it re-reads the schema on each run, a migration that would break a hand-written seed script gets picked up automatically.
- Load Testing With an Empty Database? Here's Your Problem — why an empty database makes every load-test number fiction
- PostgreSQL Test Data: A Syntax Cookbook — the raw SQL patterns for generating volumes and distributions by hand
- ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site — the migration-at-scale cousin of these volume bugs
- Enterprise Database Test Data: What It Actually Looks Like — what realistic relational test data looks like across a large schema
- Get started with Seedfast — connect your database and run your first schema-aware seed
Get Started | Documentation | Pricing
Realistic volume is the whole fix here, and Seedfast is the shortest path to a database that has it.