Most synthetic data is generated one column at a time. A name generator fills the name column, a number generator fills the price column, a date generator fills the timestamp. Each value, taken alone, looks plausible. Put them in a row together and the illusion holds for exactly as long as nobody checks whether the row makes sense.
Production-grade synthetic data is data that survives the check. Not because it looks realistic, but because it obeys the same rules your real data obeys. That distinction sounds academic until the first time a demo falls apart because a shipment was marked delivered three days before it was picked_up, or a load test throws foreign-key violations that have nothing to do with the code you are trying to test.
Realism is not a look. It is a set of invariants.
An invariant is something that must be true of every row, or across many rows, no matter what else varies. Real datasets are dense with them. Synthetic datasets usually have none, because the generators that produce them never look at more than one field at a time. Here are the ones that matter most in practice.
1. Referential integrity
Every foreign key points at a row that exists. This is the floor, and column-at-a-time generators fail it constantly: an orders.customer_id of 84213 when no customer 84213 was ever created. The harder version is self-referential integrity: an employees.manager_id that points at another employee in the same table, with a realistic org depth and a small share of top-level rows that correctly have no manager at all.
2. Derived values that actually compute
If tax = gross_pay × 0.22 in production, it must equal that in the synthetic copy, not a random number in a plausible range. The same goes for end_date = start_date + 14 days, or a username built as lower(first + "." + last). A generator that fills these independently produces rows where the tax does not match the pay, which is worse than useless: it is data that will teach a model, or a test, the wrong relationship.
3. Conservation across rows
Some truths only exist between rows. A running account balance must equal the previous balance plus the current transaction, ordered by time, footing to a known closing figure. Allocations across a set of windows must sum to a target. A delivery route’s legs must connect end-to-end. None of this is visible looking at a single row, and none of it survives a generator that produces rows independently.
txn_id amount balance TXN-084210 -4,125.07 25,365.93 TXN-084221 +547.91 25,913.84 TXN-084232 -3,332.34 22,581.50 TXN-084241 +1,387.62 23,969.12 opening 29,491.00 + Σ = closing 23,969.12 ✓
Each balance is the one above it plus this row’s amount. Generate the amount and balance columns independently and this reconciliation is gone.
4. State machines, not status soup
Order lifecycles, claim workflows, patient journeys: a status column is rarely a free choice. It is a position in a graph of legal transitions with realistic dwell times between them. consented → screened → enrolled → dosed → completed is a path; completed → screened is not. Pick statuses at random and roughly half of any multi-event entity’s history becomes physically impossible.
5. Geography that agrees with itself
A row with city Mumbai should have country India, bill in INR, and carry a locale-appropriate customer name. When city, country, currency, and name are each sampled from their own independent list, you get customers named after one continent, living on another, paying in a third. It reads as obviously fake to anyone who glances at it, and it quietly poisons any analysis that joins on geography.
6. Distributions that hold their shape
Real numeric columns are rarely uniform. Amounts are usually long-tailed: many small, a few very large. Leading digits often follow Benford’s law. Scores cluster in a bell. “Production-grade” means the synthetic column carries the distribution you asked for, within stated bounds, not a flat spread of random values that happens to fall in the right range.
The tell: does it survive a join and a sort?
You do not need a framework to sanity-check synthetic data. You need two operations that column-at-a-time generation cannot fake:
- Join it. Every foreign key should resolve. Grouped aggregates should be sensible. If a fifth of your order rows point at customers that do not exist, you are holding formatted noise.
- Sort it. Order an entity’s events by time. Do the statuses walk a legal path? Do the running totals foot? Do the dates only move forward? Sorting is where independent generation reveals itself, because it forces rows into a sequence they were never built to satisfy.
Data that passes both is doing something a per-column generator structurally cannot: it was produced with every row aware of the others.
The short version. Production-grade is not “looks like real data.” It is “obeys the rules real data obeys”: referential integrity, exact derived values, cross-row conservation, legal state transitions, geographic consistency, and the right distributions. Each is an invariant a human or a test can verify, and each is one that generating a column at a time will silently violate.
This is the bar CrossRow is built to clear. Every dataset is generated with its rows in the context of one another, then checked against these invariants and scored before you ever see it. The next guide looks at the flip side: exactly how the ordinary approach (random, well-formatted, independent values) breaks the tests you were counting on.