All posts

Faker Doesn't Know What Your Data Means

By Mikhail Shytsko, Founder at Seedfast ·

A single row of Faker output looks flawless. Call faker.name(), faker.email(), and faker.date_time_this_decade(), and back come Gloria Hettinger, gloria.hettinger@example.org, and a timestamp somewhere in 2023, each value drawn from a distribution that fits its column. Stack ten thousand rows like it and every column still survives inspection on its own.

What none of those rows carry is a relationship to the row beside them, or to the row in the next table over. That gap is the whole subject here, because realistic test data usually gets treated as a property of columns (realistic names, well-formed emails) when the realism that actually matters lives in the joins between them. Faker is the convenient example rather than the target; the same blind spot shows up in any generator that fills one column at a time.

Generate a users table and an orders table in two separate Faker loops and each table looks right in isolation. The orders table has a user_id column full of integers in a believable range, none of them null, all of them the correct type. Whether any of those integers points at a user you actually generated is a question Faker was never asked.

Faker-generated users and orders rows where order user_id values match no generated user id

The screenshot above is the ordinary result: five users numbered 1 through 5, five orders pointing at user IDs in the hundreds, a child table that inserts cleanly only because no constraint stood in the way. Add a real REFERENCES users(id) and the same seed script dies on the first orphaned row instead.

Coherent generation produces the opposite artifact. Every user_id in orders gets chosen from the set of users that exist, so a join across the two tables returns every row rather than silently dropping the orphans.

psql JOIN across Seedfast-seeded users and orders showing every foreign key resolving

Getting that join to resolve completely is the floor, and clearing it proves almost nothing on its own. It is necessary, and nowhere near sufficient, which is the rest of this article.

This isn't a defect in Faker, it's the shape of the API. Each provider call returns a value for one domain from a source of randomness that holds no state about the calls before it or the columns around it, which is exactly what makes the library fast, composable, and easy to reason about. As the community puts it, "Faker generates individual field values independently, with no concept of relationships." A first_name and a last_name in the same row have no idea they share a row, and an email two columns over won't contain either of them unless you wire that up by hand.

For a great deal of work, that is the right tool. A unit test that needs one well-formed email, a single flat table with no foreign keys, a throwaway fixture you discard right after the assertion, none of these need cross-row reasoning, and reaching for something heavier only adds ceremony. The trouble starts when the data has to hold together, not merely look right column by column.

Column values can each be individually plausible and still be collectively wrong, and the clearest case is numeric distribution. Faker will fill an amount column with values spread uniformly across a range, and uniform is precisely what money is not.

Real transaction amounts, account balances, invoice totals, and most quantities that span several orders of magnitude follow Benford's law, their leading digit landing on 1 about 30 percent of the time and on 9 under 5 percent. Balances skew log-normal, a few large accounts sitting over a long tail of small ones. Per-user activity skews Zipfian, a handful of power users producing most of the rows while everyone else trails off. A uniform column reproduces none of these shapes.

The distribution matters because your code and your database both read it. Postgres plans a query from the statistics it has gathered on a column, so a flat spread over ten thousand distinct values can steer the planner toward a sequential scan where production, with its skew, would reach for an index (or the reverse). A SUM over uniform amounts settles near a total that real data would never land on. Anything that ranks or thresholds, a top-accounts panel or a fraud rule that fires above a percentile, behaves against this data in a way it never will against the real thing. This failure is semantic rather than structural, the rows staying perfectly valid while meaning something the domain would never produce.

Coherence has a second axis, one running across rows the schema connects rather than across columns in a single row. A foreign key enforces that the referenced row exists, and enforces nothing about whether the reference makes any sense.

So an orders row can point at a genuine customer and still carry a placed_at earlier than that customer's created_at, an order placed by an account that didn't exist yet. A subscriptions row can reference a billing period that never ran. A shipments row can belong to an order nobody ever paid for. Each of these satisfies referential integrity while being obvious nonsense, the sort a reviewer catches at a glance and a constraint check is built to ignore.

This is how a suite stays green against seeded rows and still sails past the bug a coherent dataset would have exposed. Let your E2E fixtures hold orders older than their own customers, and any test that should exercise an account-age or tenure calculation never meets a row that would break it.

Put the two axes together and a working definition falls out. Realistic test data has to hold cross-column coherence inside each row, cross-row coherence across the foreign-key graph, and distributions shaped like the domain rather than like a random number generator. Meeting all three by hand means encoding the schema's dependency order, its temporal rules, and its statistical profile into fixture code, then re-encoding them after every migration that moves any of it.

That maintenance burden is why most data seeding tools stop at column values and hand the relational and semantic work back to you. Closing the gap means reading the schema itself, on every run, instead of a snapshot that drifts the moment someone ships an ALTER TABLE.

Seedfast does that read live. It connects to your Postgres, derives insert order from the foreign-key graph, and generates values inferred from each column's role, so a placed_at lands after the customer's created_at and an amount column comes out shaped like money instead of like random(). You describe the scenario in plain language and it fills the database in one command:

seedfast seed --scope "500 customers with orders, payments, and realistic balances"

Inside CI it runs as a pipeline step against a connection string, and inside an AI coding agent it runs through an MCP server, the agent calling the seedfast_run tool rather than writing a throwaway seed script that loses the foreign-key graph three tables deep. The broader comparison of test data generation methods covers where this sits next to SQL dumps and factory code, and the playbook on how to generate test data with AI covers the agent workflow in depth.

Faker output is one narrow kind of synthetic data, not the whole category. Synthetic data means any data generated rather than sampled from production, which spans everything from a single fabricated email to a fully coherent multi-table dataset with production-shaped distributions. Faker occupies the column-independent end of that range, so "faker vs synthetic data" compares a specific library against a broad category rather than weighing two like-for-like options.

A Faker-seeded database passes tests because most assertions check the happy path at tiny volume, where individual column values are all that gets exercised. Staging runs real queries against real row counts, where insert order, cross-row coherence, and distribution shape all begin to matter, and where the query planner picks different plans than it did against a handful of uniform rows. The data was never wrong in a way a small green test could see.

Cross-column coherence is the property that the values in a row agree with each other and with the rows they reference, so an order lands after the account that placed it existed and an invoice total matches the sum of its line items. Column-independent generators cannot produce it, because each value is drawn without reference to the others. The line it draws is between data that is individually plausible and data that is jointly true.

Faker stops being enough the moment correctness depends on relationships between rows rather than the format of a single value. As long as what you need is a well-formed value in an isolated column, it stays the right tool. Once the thing under test spans a foreign key, relies on a signup-before-order ordering, or reads a production-shaped distribution, test data quality turns into a schema-level property that a field-at-a-time generator can't reach.

For years, filling a database with per-column fakes was simply what generating test data meant, and for the narrow schemas of that era it was mostly fine. Schemas since then have grown wider and deeper, tests have crept closer to production shapes, and query planners have gotten better at exploiting the very statistics that uniform data fakes, so the bar for what counts as realistic has quietly risen from plausible columns to coherent, correctly-shaped, relationally-valid rows. A generator that can't read your schema can't clear that bar, however convincing its street addresses look. Seedfast reads the schema on every run and generates data that holds together across the whole graph, and its 30-day free trial takes no card.