4 min read

Faker Works Fine Until Your Schema Has Foreign Keys

Every test data conversation starts the same way. Someone says "just use Faker" and everyone nods, because for about two weeks it genuinely works.

You write a loop. It fills users with plausible names and email addresses. The output looks great. You feel good about yourself.

Then you try to fill orders.

The moment it breaks

An order needs a user_id. Faker will happily give you a random UUID or a random integer, and your insert will either fail on the constraint or, worse, succeed because nobody added the constraint.

So you do the obvious thing. You generate the users first, keep the IDs in a list, and pick randomly from that list when you generate orders. Fine. That works.

Now add order_items, which references both orders and products. Now add shipments, which references orders and has a status that only makes sense for orders that were actually paid. Now add refunds, which should only exist for shipped orders, and never for more than the order total.

You are now writing a program that understands your business. That program lives in a file called seed.py that one person wrote, nobody reviews, and everybody is scared of.

The part people underestimate

The problem is not generating values. Generating values is easy. The problem is that a database schema is a graph, and filling a graph correctly means walking it in the right order and remembering what you did at each step.

Get the order wrong and you get foreign key violations. Handle it by turning off constraint checks, which is what a lot of scripts quietly do, and you get something worse: data that inserts cleanly and is silently wrong. Orders belonging to users who do not exist. Line items pointing at deleted products.

That data will not crash your tests. It will pass them. And then a report will show revenue attributed to nobody, and someone will spend a day thinking they have a bug in the reporting code.

I have watched people lose an afternoon to that exact thing. The bug was not in the code. The bug was in the fixture.

Distributions matter more than you think

There is a second problem underneath the first one.

Random selection gives you a uniform distribution. Every user gets roughly the same number of orders. Every product appears about as often as every other product.

Real data never looks like that. A handful of customers account for most of the volume. A few products dominate the catalogue. Some users have one order from 2019 and nothing since.

This matters because uniform data hides exactly the problems you are testing for. Your query looks fast because no single user has ten thousand rows. Your pagination looks correct because no result set is lopsided. Your index looks unnecessary because the planner has no skew to deal with.

Then you go to production, where one customer has 40,000 orders, and the page times out.

What actually needs to happen

Filling a relational schema properly means doing a few things in order:

Read the schema first. Not a hand written config that describes the schema, the schema itself. Tables, columns, types, nullability, and the foreign keys between them.

Work out a safe insert order from the foreign key graph. Parents before children. Detect cycles and deal with them explicitly rather than pretending they do not exist.

Generate child rows against parents that actually exist, by keeping the generated keys and drawing from them, not by guessing.

Respect the constraints that are already declared. If a column is NOT NULL, fill it. If it is unique, do not collide. If there is a check constraint, do not violate it.

None of this is conceptually hard. All of it is tedious, and all of it rots the moment someone adds a column.

Why I built SeedQL around this

SeedQL introspects the target database, builds the foreign key graph, and generates in dependency order so child rows reference parents that exist. That is the whole reason it exists. Not because generating names is interesting, but because keeping a hundred tables referentially consistent by hand is miserable work that nobody wants to own.

You point it at a Postgres, MySQL, or SQLite database, it reads the schema, and it fills it. When someone adds a table next sprint, it reads that too.

To be clear about what that does and does not give you. It gives you data that is structurally valid and that joins correctly. It does not know that your status column has an unwritten rule where refunded implies a prior shipped, unless that rule is expressed as a constraint. Business logic that lives only in your application code is still yours to encode.

But structural correctness is most of the pain, and it is the part that breaks silently.

If you take one thing

Your seed script is not a script. It is a small, undocumented model of your data model, maintained by whoever touched it last, and it drifts every time the schema changes.

That is fine when you have six tables. It stops being fine somewhere around thirty, and by then it is load bearing.

Worth checking whether yours is still telling the truth.