A developer's guide to why independent row generation fails for relational systems, and how to fix it in under a minute
The Problem Every Backend Dev Has Hit
You need test data. So you reach for Faker, or Mockaroo, or write a quick script with numpy.random.
It works great, until your schema has more than one table.
// This "works" but it's already broken
const users = Array.from({ length: 1000 }, () => ({
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
}));
const orders = Array.from({ length: 5000 }, () => ({
id: faker.string.uuid(),
userId: faker.string.uuid(), // <-- this ID doesn't exist in `users`
amount: faker.commerce.price(),
}));
That userId field looks fine. It's a valid UUID format. It passes type checks. And it references a user that doesn't exist anywhere in your users table.
Run your app against this and you'll get orphaned foreign keys, broken joins, and integration tests that pass locally but reveal nothing about how your actual queries will behave once the join clause runs.
This is the single most common failure mode in test data generation, and almost nobody talks about it because faker-style tools were never built to solve it.
Why Independent Row Generation Doesn't Scale to Real Schemas
Faker and Mockaroo generate independent rows. Each table is populated in isolation, with no concept of the other tables around it. That's fine for a single flat CSV. It falls apart the moment your schema has: https://db.synthehol.ai/
- Foreign keys — orders referencing users, line items referencing orders, refunds referencing payments
- Composite unique constraints — one review per user per product, one booking per room per date range
- Correlated columns across tables — lifetime value that should scale with order count, account tenure that should predict loan approval likelihood
- Temporal ordering — a refund can't happen before the order it refunds, a subscription can't renew before it starts
None of this is exotic. It's just... what a real production schema looks like. And most synthetic data tools quietly assume you don't have it.
What "Relational" Actually Means for Test Data
Here's the distinction that matters: generating rows vs. generating a database.
| Row-Level Generators (Faker, Mockaroo) | Relational Generators | |
|---|---|---|
| Output | Independent tables | Linked, joinable tables |
| Foreign keys | Random values, often invalid | Resolve to real parent rows |
| Cross-table correlation | None | Preserved (e.g., LTV scales with order count) |
| Constraint validation | Manual, after the fact | Built into generation |
| Temporal consistency | Not handled | Enforced (no refund before order) |
If you've ever written a post-processing script to "fix up" your fake data so the foreign keys actually resolve, you've already discovered why this distinction matters. You were doing, by hand, what a relational generator should do automatically.
A Quick Way to See the Difference
Try this experiment on your own schema. Take any two related tables — say orders and order_items — and check referential integrity after generating fake data with your current tool:
import pandas as pd
orders = pd.read_csv('fake_orders.csv')
order_items = pd.read_csv('fake_order_items.csv')
orphaned = order_items[~order_items['order_id'].isin(orders['order_id'])]
print(f"Orphaned order_items: {len(orphaned)} out of {len(order_items)}")
If you're using independent row generation, that orphaned count is almost never zero. Every orphaned row is a foreign key that will throw an error, fail silently, or worse, get "handled" by application code masking a data integrity bug that won't show up until production.
How Relational Generation Actually Works
A schema-aware generator has to do three things that Faker-style tools skip entirely:
1. Understand generation order. Parent tables generate before child tables. Users exist before orders exist before order items exist. This sounds obvious until you're hand-writing a generation script and realize you have circular dependencies to untangle.
2. Sample foreign keys from existing parent rows, not from a random UUID generator:
# Instead of this:
order['user_id'] = fake.uuid4()
# You need this:
order['user_id'] = random.choice(existing_user_ids)
3. Preserve statistically realistic correlations across the relationship. A user with 2 years of tenure and 40 orders should have a meaningfully different lifetime value than a user with 2 weeks of tenure and 1 order. If your generator can't express that, your test data will train models, validate UI edge cases, and stress-test pipelines against a world that doesn't resemble production.
Where This Gets Genuinely Hard
If you've tried to build this yourself, you know where it gets painful fast:
- Many-to-many relationships (users ↔ roles ↔ permissions) need junction tables generated with realistic overlap, not random pairings
- Composite unique constraints (
UNIQUE(user_id, product_id)on a reviews table) need generation-time collision checking, not post-hoc deduplication - Self-referencing tables (an
employeestable with amanager_idpointing to another row in the same table) need cycle-safe generation - Schema migrations mean your fake data generator needs to stay in sync with your actual DDL, or it silently drifts out of date
At a certain schema complexity, hand-rolling this stops being a reasonable use of engineering time. This is exactly the gap SyntheholDB was built to close.
What SyntheholDB Does Differently
Instead of generating rows, SyntheholDB generates databases. You describe your schema, or import your actual CSVs or DDL, and it handles the relational logic for you: https://db.synthehol.ai/
Define Your Schema → Generate → Export
- Foreign keys resolve automatically — every child row references a real, existing parent row, not a random UUID https://db.synthehol.ai/
- Composite unique constraints, non-overlapping windows, and monotonic timelines are validated and repaired before export https://db.synthehol.ai/
- Cross-column correlations are tunable — lifetime value scales with order count, salary scales with tenure, the derived metrics stay believable instead of arbitrary https://db.synthehol.ai/
- Zero real PII, by construction — values are sampled from statistical models, never copied or masked from production data, so there's nothing sensitive to review before you ship the dataset to a dev environment https://db.synthehol.ai/
- Multiple export formats — CSV works on every plan, with SQL dumps and Parquet available for larger workflows https://db.synthehol.ai/
You can start from a template, upload existing CSVs for automatic schema inference, or just describe your data model in plain English and let the generation engine build the tables, relationships, and constraints for you. https://db.synthehol.ai/
Try It on Your Own Schema
The fastest way to see the difference is to run your actual schema through it. The free tier is capped at 1,000 rows per generation, which is enough to validate referential integrity on a real multi-table schema before you commit to anything. https://db.synthehol.ai/
👉 Generate your first relational dataset free at https://db.synthehol.ai/
If you're testing this against a schema with foreign keys, composite constraints, or many-to-many joins, drop a comment below; I'd genuinely like to hear what breaks and what doesn't. That feedback loop is exactly how tools like this get better for the rest of us.
Top comments (0)