Multi-table Databases
A single Faker table answers "what does a row look like?". A database also has to answer "which rows belong to which": every order points at an existing customer, each customer has a plausible number of orders, only shipped orders have a shipment, and the payment equals the sum of the order's lines.
Passing tables= to fit() switches the FakerSynthesizer to schema mode. One schema describes several tables and the relationships between them. sample() generates them in dependency order and returns a MultiDataset in which every foreign key resolves. It is the same class as single-table mode, and fit(metadata, ...) behaves exactly as before.
from ydata.synthesizers import FakerSynthesizer
synth = FakerSynthesizer()
synth.fit(tables="shop.yaml", calculated_features=features) # path, dict or FakerSchema
db = synth.sample(sample_size={"customers": 2_000, "products": 300}, random_state=42)
db["orders"].to_pandas() # one Dataset per table
synth.error_labels_ # one row per injected error
A schema with one table returns a Dataset, and a schema with several returns a MultiDataset.
The schema
The layout follows the LLM synthesizer's schema where both engines mean the same thing (tables, primary_key, foreign_keys, scaffold, table_errors). Each table keeps the single-table Faker configuration you already know: columns, distributions, conditions, sequence, totals, derived_tables and errors. Every Faker configuration loads into one type, FakerSchema (load_faker_schema(path), or fit(tables=path) directly). The reserved top-level tables: key marks a database; a file without it is a single table, loaded as a one-table schema named data.
tables:
customers:
primary_key: customer_id
key_format: "CUS-{n:06d}"
columns:
customer_id: {datatype: string, vartype: string}
segment: {datatype: categorical, vartype: string, categories: {consumer: 80, business: 20}}
city: {datatype: string, vartype: string, provider: city}
orders:
primary_key: order_id
foreign_keys:
- column: customer_id
referenced_table: customers
cardinality: {distribution: poisson, lam: 2.5, min: 1, max: 12}
context_columns: [segment]
columns:
order_id: {datatype: numerical, vartype: int}
customer_id: {datatype: string, vartype: string}
shipping_method: {datatype: categorical, vartype: string,
categories: {standard: 70, express: 25, pickup: 5}}
conditions:
- column: shipping_method
rules: [{when: {customers.segment: business}, values: {express: 0.7, standard: 0.3}}]
Table keys
| Key | Meaning |
|---|---|
primary_key |
The table's key column. Integer keys are generated as 1..N; string keys follow key_format. |
key_format |
str.format template with one n field for string keys, e.g. "OP-{n:08d}". Defaults to the table's initial plus five digits. |
foreign_keys |
Relationships to other tables (below). |
scaffold |
A structural grid, as in single-table mode. from_table may name another table of the schema, and it is resolved against the generated data. |
table_errors |
referential_integrity: [{column, rate}] — foreign key values replaced by keys that do not exist in the parent. |
description |
Free text documenting the table. It is not used for generation. |
columns, distributions, conditions, sequence, totals, derived_tables, errors |
The single-table configuration, unchanged. |
Foreign keys
| Key | Meaning |
|---|---|
column, referenced_table |
The child column and the parent table. The parent must have a primary key, and the column types must match. |
cardinality |
Children per parent. Either a distribution spec ({distribution: poisson, lam: 2, min: 1, max: 8}, clipped by min/max) or integer bounds ({min: 1, max: 3}, uniform). With cardinality the foreign key drives the table's row count, and at most one foreign key per table may declare it. |
where |
Parent filter: column → value or list of values, ANDed across columns. Only matching parents get children, or can be sampled. |
unique |
For sampled foreign keys: each parent is used at most once. |
context_columns |
Parent columns available to the child during generation, usable in conditions and calculated features, and not copied into the output. Each entry is a column of the parent, or a path to an ancestor's column through the parent's foreign keys (country_id.iso_code). See Parent context below. |
A foreign key without cardinality is sampled: each child row picks one of the (filtered) parents.
Schema-mode column keys
| Key | Meaning |
|---|---|
provider |
Any Faker provider by name (city, state_abbr, company, street_name, ...) for string columns not covered by characteristic. |
_-prefixed column names |
Helper columns. They are generated and usable by conditions and calculated features, then dropped from the output. Use them for mixtures, hidden segments or rare anomaly flags. A declared primary or foreign key is always kept, whatever its name (e.g. _id). |
Prompts are not part of a Faker schema. Use description to document intent.
Parent context
context_columns makes parent values available to the child during generation. Each requested column can be addressed through the foreign key it comes from, as <fk column>.<column>. A column requested through only one foreign key to its parent can also be addressed with the short form <Parent>.<column>, as in the customers.segment example above.
The foreign key form matters when a table references the same parent twice. A flight has an origin and a destination airport, and its fare depends on both countries:
flights:
primary_key: flight_id
foreign_keys:
- {column: origin_airport, referenced_table: airports, context_columns: [country]}
- {column: destination_airport, referenced_table: airports, context_columns: [country]}
columns:
flight_id: {datatype: numerical, vartype: int}
origin_airport: {datatype: string, vartype: string}
destination_airport: {datatype: string, vartype: string}
fare: {datatype: numerical, vartype: float, min: 50, max: 2000}
conditions:
- column: fare
rules:
- when: {origin_airport.country: US, destination_airport.country: CA}
distribution: {distribution: uniform, low: 1500, high: 2000}
default: {distribution: {distribution: uniform, low: 50, high: 600}}
Here airports.country would not say which airport is meant, so fit() rejects it wherever it is read and names the two foreign key forms to use instead.
An entry can also follow foreign keys further up. Each segment before the last is a foreign key of the table reached so far, and the last segment is a column of the table it ends on. If the country is its own table (airports.country_id references countries), the same rule reads the country's code two hops away:
- {column: origin_airport, referenced_table: airports, context_columns: [country_id.iso_code]}
- {column: destination_airport, referenced_table: airports, context_columns: [country_id.iso_code]}
...
- when: {origin_airport.country_id.iso_code: US, destination_airport.country_id.iso_code: CA}
Calculated features read the same names with the table in front, e.g. flights.origin_airport.country_id.iso_code. The last segment may be a declared column, a _ helper column or a calculated feature of the table it reaches. Every other segment must be a foreign key.
Which rows exist
Each table's rows come from exactly one driver, checked in this order:
| Driver | When | Rows |
|---|---|---|
| Supplied data | The table is in existing_data |
The supplied rows, with their keys kept |
| Parent seed | The parent of a supplied table | One row per distinct foreign key value used by the supplied table |
| Scaffold | scaffold: is declared |
The materialized grid |
| 1:1 extension | The primary key is also a foreign key | One row per (filtered) parent, sharing its key |
| Driving foreign key | A foreign key declares cardinality |
The sum of the drawn counts over the (filtered) parents |
| Root | None of the above | sample_size |
sample_size sizes root tables only: an int applies to every root table, and {table: rows} sizes them individually. Sizing a table whose rows come from its relationships raises an error that names the root tables.
Calculated features across tables
Calculated features use table.column addressing. Features within one table run inside that table's generation, exactly as in single-table mode. A feature that reads another table declares reference_keys:
{
"calculated_features": "payments.amount",
"function": lambda line_total, order_id: (
pd.DataFrame({"order_id": order_id, "amount": line_total})
.groupby("order_id", as_index=False).sum()),
"calculated_from": ["order_items.line_total"],
"reference_keys": {"source": "order_items.order_id", "target": "payments.order_id"},
}
The function receives the source columns, then the source key. It returns either a DataFrame with the source key and the outputs (an aggregate, as above) or values aligned with the source rows (a copy). The result is merged onto the target by the key before the target is generated, so the target's conditions can use it. A cross-table feature also orders generation (source before target), and a cycle is reported with its path.
Errors and labels
Each table's errors block works as in single-table mode. It may be written unkeyed ({columns: ..., table: ...}) or keyed by table name, as in a single-table file. Errors are applied once every table exists, so children and calculated features are always derived from clean parents, and the corruption only lands in the delivered data.
synth.error_labels_ lists every error injected by the last sample(): table, row, pk_value, column, error_type, original_value, injected_value. In single-table mode it is set as well when errors are configured (without pk_value). After schema-mode sampling it is an empty DataFrame when no errors are configured; single-table mode leaves it as None. Every fit() resets it to None, so labels from a previous model never carry over.
Before errors are injected, the clean data is checked against the schema's keys and relationships; a violation there means a contradictory schema and is raised as a warning.
Checking referential integrity
To check a generated database, run the ReferentialIntegrityValidator from ydata-report on the MultiDataset. It reports primary key violations (duplicates, nulls), orphan foreign keys, parent coverage and children per parent, per table and in total:
from ydata.metadata import MultiMetadata
from ydata.report import ReferentialIntegrityValidator
results = ReferentialIntegrityValidator(synthetic_data=db, metadata=MultiMetadata(db)).validate()
results["summary"]["total_fk_violations"] # orphans across all tables
results["tables"]["shipments"]["foreign_keys"]["order_id"]["orphan_count"]
With table_errors.referential_integrity, the orphans it finds are exactly the rows labelled referential_integrity in error_labels_, as the worked example below asserts.
Supplied tables
existing_data={"orders": df} keeps the rows and keys of a real table and generates only the schema columns it lacks. Parents it references are seeded from the foreign key values it actually uses, so the generated database stays consistent with the supplied rows. anchor_tables=["orders"] additionally requires the supplied table to carry a unique, non-null primary key.
Worked example
An online shop: customers, products, orders, order lines, payments, shipments and reviews. It uses every relationship type, cross-table calculated features, errors and labels, and asserts each guarantee.
# Multi-table FakerSynthesizer configuration: an online shop database.
#
# The top-level `tables:` key makes this a database: several tables in one
# FakerSchema (load_faker_schema). Each table carries the single-table blocks you already
# know (columns, distributions, conditions, errors, ...) plus its relational
# declarations: primary_key, key_format, foreign_keys, table_errors.
#
# customers ──< orders ──< order_items >── products
# │
# ├── payments (1:1, every non-cancelled order)
# └── shipments (1 per shipped / delivered order)
# └── reviews (sampled: at most one per delivered order)
tables:
customers:
primary_key: customer_id
key_format: "CUS-{n:06d}"
columns:
customer_id: {datatype: string, vartype: string}
name: {datatype: string, vartype: string, characteristic: name}
email: {datatype: string, vartype: string, characteristic: email}
city: {datatype: string, vartype: string, provider: city} # any Faker provider
segment: {datatype: categorical, vartype: string,
categories: {consumer: 80, business: 20}}
signup_date: {datatype: date, vartype: date, min: "2023-01-01", max: "2025-12-31",
format: "%Y-%m-%d"}
products:
primary_key: sku
key_format: "SKU-{n:05d}"
columns:
sku: {datatype: string, vartype: string}
category: {datatype: categorical, vartype: string,
categories: {books: 35, electronics: 20, home: 30, toys: 15}}
list_price: {datatype: numerical, vartype: float, min: 2, max: 2500}
conditions:
- column: list_price
rules:
- {when: {category: electronics}, distribution: {distribution: lognormal, mean: 5.0, sigma: 0.7}}
- {when: {category: books}, distribution: {distribution: lognormal, mean: 2.8, sigma: 0.3}}
default: {distribution: {distribution: lognormal, mean: 3.5, sigma: 0.6}}
orders:
primary_key: order_id # integer key -> 1..N
foreign_keys:
- column: customer_id
referenced_table: customers
cardinality: {distribution: poisson, lam: 2.5, min: 1, max: 12} # orders per customer
context_columns: [segment] # -> customers.segment
columns:
order_id: {datatype: numerical, vartype: int}
customer_id: {datatype: string, vartype: string}
order_date: {datatype: date, vartype: datetime, min: "2026-01-01 00:00:00",
max: "2026-06-30 23:59:59", format: "%Y-%m-%d %H:%M:%S"}
status: {datatype: categorical, vartype: string,
categories: {placed: 10, shipped: 25, delivered: 55, cancelled: 10}}
shipping_method: {datatype: categorical, vartype: string,
categories: {standard: 70, express: 25, pickup: 5}}
conditions:
- column: shipping_method # business customers favour express
rules:
- {when: {customers.segment: business}, values: {express: 0.7, standard: 0.3}}
default: {values: {standard: 0.72, express: 0.2, pickup: 0.08}}
order_items:
primary_key: item_id
key_format: "ITM-{n:08d}"
foreign_keys:
- column: order_id
referenced_table: orders
cardinality: {distribution: poisson, lam: 1.8, min: 1, max: 10} # lines per order
- column: sku # no cardinality: sampled from products
referenced_table: products
columns:
item_id: {datatype: string, vartype: string}
order_id: {datatype: numerical, vartype: int}
sku: {datatype: string, vartype: string}
quantity: {datatype: numerical, vartype: int, min: 1, max: 20}
_discount_tier: {datatype: categorical, vartype: string, # helper: dropped
categories: {none: 80, promo: 20}}
distributions:
quantity: {distribution: poisson, lam: 1.5, min: 1}
# unit_price (copied from the product) and line_total are calculated features
errors:
columns:
quantity: {out_of_range: {rate: 0.005, factor: 50}} # rogue bulk lines
payments:
primary_key: order_id # the PK is also the FK: a 1:1 extension
foreign_keys:
- column: order_id
referenced_table: orders
where: {status: [placed, shipped, delivered]} # no cancelled orders
columns:
order_id: {datatype: numerical, vartype: int}
method: {datatype: categorical, vartype: string,
categories: {card: 70, paypal: 20, bank_transfer: 10}}
# amount = sum of the order's line totals (cross-table calculated feature)
shipments:
primary_key: shipment_id
key_format: "SHP-{n:07d}"
foreign_keys:
- column: order_id
referenced_table: orders
cardinality: {min: 1, max: 1}
where: {status: [shipped, delivered], shipping_method: [standard, express]}
context_columns: [shipping_method]
columns:
shipment_id: {datatype: string, vartype: string}
order_id: {datatype: numerical, vartype: int}
carrier: {datatype: categorical, vartype: string,
categories: {UPS: 40, FedEx: 35, DHL: 25}}
transit_days: {datatype: numerical, vartype: int, min: 1, max: 10}
conditions:
- column: transit_days
rules:
- {when: {orders.shipping_method: express},
distribution: {distribution: triangular, left: 1, mode: 1, right: 2}}
default: {distribution: {distribution: triangular, left: 2, mode: 4, right: 8}}
table_errors:
referential_integrity: [{column: order_id, rate: 0.01}] # orphan shipments
reviews:
primary_key: review_id
foreign_keys:
- column: order_id
referenced_table: orders
unique: true # sampled without replacement
where: {status: delivered}
columns:
review_id: {datatype: numerical, vartype: int}
order_id: {datatype: numerical, vartype: int}
rating: {datatype: categorical, vartype: int,
categories: {1: 5, 2: 5, 3: 15, 4: 35, 5: 40}}
"""
Example: a multi-table database with the FakerSynthesizer.
Passing `tables=` to `fit()` switches the FakerSynthesizer to schema mode: one
schema describes several tables and the relationships between them, and
`sample()` returns a MultiDataset in which every foreign key resolves.
The schema (faker_multitable_database.yaml) is an online shop:
customers ──< orders ──< order_items >── products
├── payments 1:1 extension, only non-cancelled orders
├── shipments one per shipped/delivered order, with orphans injected
└── reviews sampled, at most one per delivered order
It shows each relationship type:
- driving foreign keys: `cardinality` sets how many orders each customer has
and how many lines each order has
- sampled foreign keys: each order line picks a product; `unique` makes each
review pick a different order
- `where`: only matching parents get children (or can be sampled)
- 1:1 extension: the payments primary key is also its foreign key
- `context_columns`: parent columns (customers.segment, orders.shipping_method)
drive the child's conditions without being copied into it
- cross-table calculated features: the unit price is copied from the product,
and the payment amount is the sum of the order's lines
- errors and `table_errors.referential_integrity`, with every injected cell
recorded in `synth.error_labels_`
- checking the result with ydata-report's ReferentialIntegrityValidator
Run with: python faker_multitable_database.py
"""
from pathlib import Path
import pandas as pd
from ydata.metadata import MultiMetadata
from ydata.report import ReferentialIntegrityValidator
from ydata.synthesizers import FakerSynthesizer
HERE = Path(__file__).parent
# ---------------------------------------------------------------------------
# Calculated features: `table.column` addressing. Cross-table features declare
# reference_keys; the function receives the source columns, then the source key.
# ---------------------------------------------------------------------------
DISCOUNT = {"none": 1.0, "promo": 0.85}
calculated_features = [
{ # copy each product's list price onto the order lines that reference it
"calculated_features": "order_items.unit_price",
"function": lambda price, sku: price, # row-aligned with products
"calculated_from": ["products.list_price"],
"reference_keys": {"source": "products.sku", "target": "order_items.sku"},
},
{ # intra-table: quantity x price, with the helper column's discount
"calculated_features": "order_items.line_total",
"function": lambda qty, price, tier: (qty * price * tier.map(DISCOUNT)).round(2),
"calculated_from": ["order_items.quantity", "order_items.unit_price",
"order_items._discount_tier"],
},
{ # aggregate: the payment is the sum of the order's lines
"calculated_features": "payments.amount",
"function": lambda line_total, order_id: (
pd.DataFrame({"order_id": order_id, "amount": line_total})
.groupby("order_id", as_index=False).sum().round(2)),
"calculated_from": ["order_items.line_total"],
"reference_keys": {"source": "order_items.order_id", "target": "payments.order_id"},
},
]
synth = FakerSynthesizer(locale="en")
synth.fit(tables=HERE / "faker_multitable_database.yaml",
calculated_features=calculated_features)
# only ROOT tables are sized; every other table follows from its relationships
db = synth.sample(
sample_size={"customers": 2_000, "products": 300, "reviews": 1_000},
random_state=42,
)
t = {name: db[name].to_pandas() for name in db.keys()}
customers, products, orders = t["customers"], t["products"], t["orders"]
items, payments, shipments, reviews = t["order_items"], t["payments"], t["shipments"], t["reviews"]
for name, frame in t.items():
print(f"{name:12s} {len(frame):>7,} rows {list(frame.columns)}")
# ---------------------------------------------------------------------------
# What the schema guarantees
# ---------------------------------------------------------------------------
# keys: key_format for strings, 1..N for integers
assert customers["customer_id"].str.fullmatch(r"CUS-\d{6}").all()
assert orders["order_id"].tolist() == list(range(1, len(orders) + 1))
# driving FK: every customer has between 1 and 12 orders
per_customer = orders.groupby("customer_id").size()
assert set(per_customer.index) == set(customers["customer_id"])
assert per_customer.between(1, 12).all()
# context: business customers get express or standard shipping, never pickup
business = set(customers.loc[customers["segment"] == "business", "customer_id"])
assert not ((orders["customer_id"].isin(business)) & (orders["shipping_method"] == "pickup")).any()
assert "customers.segment" not in orders.columns # context is not copied
# sampled FK + cross-table copy: the line price is the product's list price
merged = items.merge(products, on="sku")
assert len(merged) == len(items)
assert (merged["unit_price"] == merged["list_price"]).all()
assert "_discount_tier" not in items.columns # helper column dropped
# 1:1 extension with where: one payment per non-cancelled order, nothing else
paid = set(orders.loc[orders["status"] != "cancelled", "order_id"])
assert set(payments["order_id"]) == paid and payments["order_id"].is_unique
# aggregate: the payment equals the order's line totals (computed on clean data,
# so it still holds where `quantity` was later corrupted by out_of_range)
expected = items.groupby("order_id")["line_total"].sum().round(2)
assert (payments.set_index("order_id")["amount"] - expected.reindex(payments["order_id"])
).abs().max() < 0.011
# where + cardinality {1, 1}: one shipment per shipped/delivered, non-pickup order
eligible = orders[orders["status"].isin(["shipped", "delivered"])
& orders["shipping_method"].isin(["standard", "express"])]
labels = synth.error_labels_
orphans = labels[labels["error_type"] == "referential_integrity"]
clean_shipments = shipments[~shipments["shipment_id"].isin(orphans["pk_value"])]
assert set(clean_shipments["order_id"]) <= set(eligible["order_id"])
assert len(shipments) == len(eligible)
# express shipments take 1-2 days
express = shipments.merge(orders[["order_id", "shipping_method"]], on="order_id")
assert express.loc[express["shipping_method"] == "express", "transit_days"].le(2).all()
# unique + where: each review is for a different delivered order
delivered = set(orders.loc[orders["status"] == "delivered", "order_id"])
assert reviews["order_id"].is_unique and set(reviews["order_id"]) <= delivered
# ---------------------------------------------------------------------------
# Ground truth of the injected errors
# ---------------------------------------------------------------------------
print("\nInjected errors:")
print(labels.groupby(["table", "column", "error_type"]).size().to_string())
assert len(orphans) == int(0.01 * len(shipments))
assert not set(orphans["injected_value"]) & set(orders["order_id"]) # orphans point nowhere
# ---------------------------------------------------------------------------
# Referential integrity of the delivered database (ydata-report): the only
# violations it finds are exactly the orphans injected on purpose
# ---------------------------------------------------------------------------
integrity = ReferentialIntegrityValidator(synthetic_data=db, metadata=MultiMetadata(db)).validate()
summary = integrity["summary"]
print(f"\nPK violations: {summary['total_pk_violations']}, "
f"FK violations: {summary['total_fk_violations']}")
assert summary["total_pk_violations"] == 0
assert summary["total_fk_violations"] == len(orphans)
assert integrity["tables"]["shipments"]["foreign_keys"]["order_id"]["orphan_count"] == len(orphans)
# reproducibility: the same random_state gives the same database
again = synth.sample(sample_size={"customers": 2_000, "products": 300, "reviews": 1_000},
random_state=42)
assert again["orders"].to_pandas().equals(orders)
print("All relationship, calculated-feature and error checks passed.")
Notes
Volume
String columns backed by provider, characteristic or regex (and neither unique nor the target of a condition) are drawn from a pool of generated values: 5,000 by default, set with fit(pool_size=...) to any positive integer. pool_size=None generates every value individually. With pooling, a six-table database with 100,000+ rows per table generates in seconds. A unique column is never pooled. A unique provider column gets one value per row without repeats, which costs at least one provider call per row; if the provider cannot produce enough distinct values (e.g. state_abbr for more than 50 rows), a warning is logged and duplicates are allowed, as in single-table mode.
Single-table arguments
In schema mode, scaffold, distributions, conditions, sequence, totals, derived_tables, errors, reference_data and table_name belong inside each table's block. Passing them to fit() together with tables= raises an error.
Starting from an LLM synthesizer schema
FakerSchema.from_llm_schema(tables) converts an LLM synthesizer schema into a Faker schema skeleton: tables, keys, relationships and categorical values are copied and prompts become description. It also returns the columns that still need ranges.
Not supported yet
Self-referencing foreign keys (for example manager_id → employees) and composite keys.
Related Materials
- Structural Scaffolds
- Distributions and Conditional Generation
- Error Injection
- Calculated Features (Business Rules)
- Faker Synthesizer overview
