SynthForge SynthForge SynthForge IO

Blog ·

Why typing 100, 100, 100, 100 across every table got old

We just shipped per-relationship cardinality ratios in SynthForge. You can now say '8 to 12 patients per doctor' instead of hand-computing row counts for every child table. Plus auto-saved defaults so last generation's settings come back next time.

features synthetic-data engineering

The friction we couldn’t unsee

Pick any non-trivial schema. Doctors, patients, visits, prescriptions. Or customers, orders, line items, refunds. Every time you went to generate data, you’d type a row count for each table by hand.

Some teams pick round numbers - 100 doctors, 1000 patients, 5000 visits - and call it close enough. The math is wrong (each doctor would have 10 patients on average, but each patient would somehow have 5 visits, which only works if patients see a single doctor across all those visits). The shape of the data is off, but for a sanity-check seed, it doesn’t matter.

It matters the moment the shape of the data is the thing under test: load-testing data, training data, anything where the cardinalities reach the system you are exercising. A query plan that handles “1 line item per order” beautifully can melt down on “200 line items per order.” Your tests have to look like production, and production has structure. Each order has somewhere between 1 and 50 line items, with most clustering around 3-5, while each warehouse carries 50-500 SKUs.

You knew this. You’d open a calculator, multiply through, and type the result into the row count box. Then you’d realize the AI agent emitted six tables instead of four and you’d start over.

What we just shipped

The SynthForge dataset generation form now lets you toggle any child table - any table that has a foreign key to a parent - from a fixed row count to a per-parent ratio.

It looks like this:

doctors:        50            (fixed)
patients:       8 to 12 per doctor      (~500 rows projected)
visits:         1 to 5 per patient      (~1,500 rows projected)
prescriptions:  0 to 3 per visit        (~2,250 rows projected)

You set the parents. SynthForge derives the children, in topological FK order. Each parent picks its own multiplier inside your range, so one doctor ends up with nine patients and the next with eleven. The projected row count sits next to each table and updates as you type.

A junction table for a many-to-many relationship - say appointments linking doctors and patients - has two parents, so you pick which side drives the count. Set “20 appointments per doctor” and the form works out what that comes to per patient and shows you. The engine also caps a per-driver count at the number of partners available, because a doctor cannot have more distinct patients than there are patients.

Top-level tables - the ones with no foreign keys to anything - stay as plain row count inputs. No new mental model for the simple case.

Auto-saved defaults

The other thing that quietly disappeared: re-typing the same numbers every generation.

Every successful dataset job now persists its row counts and ratios as the schema’s defaults. The next time you open the generation form for that schema, the inputs come back exactly the way you left them. Different browser, different machine, six months later: the defaults live on the schema record, not in your local storage.

Override anything before regenerating and the new values become the defaults. The form has memory now.

What this took, briefly

The runtime side wasn’t actually new work. SynthForge already walked tables in topological FK order during generation - it had to, so children could read their parents’ generated IDs. We just taught it to also resolve row counts in that same order: walk parents first, look up their resolved count, multiply by the ratio, write the child’s count.

The shape of the input - {"parent_table.child_table": {"min": N, "max": M}} - had been in the data model from the start, sitting unused. It was carried over for completeness and then quietly waited for someone to wire it through.

The interesting work was the user surface. The naive UI is a separate “Relationships” panel below the row counts. The version we shipped puts the toggle inline next to each child table, so you don’t have to keep two mental models. We also auto-detect FK pairs from the schema so the parent picker only shows real options.

Persistence is auto-save on every successful generation. We considered a “remember as default” checkbox but decided it added a click for behavior that’s never wrong - if your last generation worked and you’re regenerating, you almost always want the same shape.

The hardest design call was precedence. If a child has both an explicit row count AND a ratio against its parent, which wins? We picked: ratio wins. The mental model is “I set ratios, the system picks counts.” If you don’t want a ratio, switch the toggle off. Trying to honor both at once would surface confusing edge cases (what if the explicit count contradicts the ratio’s range?) and add no real expressiveness.

What’s not in this release

Two things that some users will ask for and we deliberately deferred:

Conditional ratios. “10 to 30 line items per order, but only 1 to 5 if the order is a Refund.” Useful, but it’s really a derived-rule shape, not a cardinality shape, and we’d want to design the UI carefully. Open issue.

Ratio histograms. Right now the per-parent multiplier is uniform random within your range. A more realistic distribution would be something like LogNormal (most orders have 3 items, a few have 50). We have LogNormal for column values; cross-walking it into cardinality is straightforward when the demand surfaces.

Update, September 2026

Both of those shipped, so the section above is history rather than a roadmap.

A child hanging off a single parent now takes a shape as well as a range. The dropdown beside the range offers even (per row), which draws a fresh number for every parent; long tail, which puts most parents near the low end and sends a few a long way out; and constant (all same), which picks one number and uses it for the whole table. Long tail is the LogNormal the section above was asking for.

Conditional ratios shipped as well, though not yet in the generation form. A relationship range now takes a list of cases, each with a column, a value to match, and its own range, so “1 to 5 line items when the order is a Refund, 10 to 30 otherwise” is expressible today through the API and through the MCP server’s generate tool. First matching case wins, and anything unmatched falls back to the range you set. The form gets it when the surface is designed.

Try it

app.synthforge.io. Pick any schema with foreign keys (or use the AI agent to make one). Open Generate dataset, find any child table, hit the dropdown, switch to “per parent row.” Generate. Open the form again - your inputs are still there.

It’s free. No credit card. The first 20,000 rows of a dataset are free, up to 10,000,000 rows per table; bigger runs use credits, from about 2 credits for a small dataset to 41 for a full 1,000,000-row dataset. 1 GB per download, 5 GB stored per account. The full feature set is on the data generation page.


Ready to get started?

Multi-table foreign-key integrity, AI schema design, seven SQL dialects, no credit card.