Synthetic Test Data Generation: AI, Statistics, and Referential Integrity
Synthetic test data is data that was never real. It's generated programmatically to match the statistical properties, formats, and constraints of production data — without any connection to actual users or events. As privacy regulations tighten and data breach costs rise, synthetic data has moved from a niche technique to a mainstream requirement.
This guide covers the full spectrum: from simple Faker-based generation to AI-powered tools that learn your schema's statistical distributions and generate realistic datasets at scale.
The Case for Synthetic Data
Three scenarios where synthetic data is the only responsible option:
- Privacy regulations forbid the alternative. GDPR, HIPAA, and CCPA all constrain how you can use real PII in non-production environments. Synthetic data sidesteps the constraint entirely — there's no PII to regulate.
- Production data is too sparse for testing. Edge cases don't happen often in production. A payment system might process one fraudulent transaction per thousand legitimate ones. Synthetic data lets you generate a 50% fraud rate dataset to test your fraud detection at scale.
- Testing before production. New features don't have production data yet. Synthetic data lets you build and test against realistic volumes before launch.
Faker: The Foundation
Faker (available in Python, JavaScript, Ruby, PHP, and Go) is the starting point for most synthetic data generation. It generates plausible-looking values for common data types:
from faker import Faker
fake = Faker()
# --- Addresses ---
fake.address() # "742 Evergreen Terrace, Springfield, IL 62701"
fake.street_address() # "742 Evergreen Terrace"
fake.city() # "Springfield"
fake.state() # "Illinois"
fake.zipcode() # "62701"
fake.latitude() # Decimal('44.128976')
fake.longitude() # Decimal('-73.231048')
# --- Dates and Times ---
fake.date_this_decade() # datetime.date(2023, 7, 14)
fake.date_between(start_date="-1y", end_date="today")
fake.time_series(start_date="-30d", precision=timedelta(hours=1))
# --- Company ---
fake.company() # "Acme Corp"
fake.catch_phrase() # "Streamlined bottom-line synergy"
fake.bs() # "leverage agile frameworks"
# --- Profiles ---
fake.profile() # Full dict with name, sex, address, mail, birthdate, etc.Faker shines for isolated field generation. Its weakness is that fields generated independently don't correlate — a city of "Phoenix" paired with a state of "Maine" is unrealistic. For that, you need constrained generation.
Mimesis: Performance-First Generation
Mimesis is a Python library that generates data 4–33x faster than Faker for the same field types. It's designed for bulk generation:
from mimesis import Person, Address, Finance, Food, Text
from mimesis.locales import Locale
person = Person(Locale.EN)
address = Address(Locale.EN)
finance = Finance(Locale.EN)
# Generate 100,000 user records
users = [
{
"id": i,
"name": person.full_name(),
"email": person.email(),
"age": person.age(minimum=18, maximum=80),
"phone": person.phone(),
"city": address.city(),
"country": address.country_code(),
"credit_score": finance.credit_score_type(),
}
for i in range(100_000)
]Mimesis also supports custom providers for domain-specific values:
from mimesis import Generic
from mimesis.providers.base import BaseProvider
class MedicalProvider(BaseProvider):
class Meta:
name = "medical"
def icd10_code(self) -> str:
chapter = self.random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
number = self.random.randint(10, 99)
return f"{chapter}{number}.{self.random.randint(0, 9)}"
def blood_pressure(self) -> str:
systolic = self.random.randint(90, 180)
diastolic = self.random.randint(60, 120)
return f"{systolic}/{diastolic}"
generic = Generic()
generic.add_provider(MedicalProvider)
generic.medical.icd10_code() # "C34.2"
generic.medical.blood_pressure() # "120/80"Statistical Distributions: Beyond Random
Real data doesn't follow uniform distributions. Age distributions follow a bell curve. Purchase amounts follow power laws (many small, few large). Latency follows log-normal distributions. Naive random generation misses these patterns.
Using NumPy for Distribution-Aware Generation
import numpy as np
import pandas as pd
from faker import Faker
fake = Faker()
rng = np.random.default_rng(seed=42)
n = 10_000
# Age: normally distributed around 35, std 12, clipped to 18-80
ages = rng.normal(loc=35, scale=12, size=n).clip(18, 80).astype(int)
# Purchase amounts: log-normal (many small, few large)
# Mean ~$50, occasional >$500
amounts = rng.lognormal(mean=3.5, sigma=1.2, size=n).round(2)
# Session duration: exponential (most sessions short, some very long)
session_seconds = rng.exponential(scale=180, size=n).astype(int)
# Churn probability: beta distribution (bimodal — users either stay or leave)
churn_probs = rng.beta(a=0.5, b=0.5, size=n)
df = pd.DataFrame({
"user_id": range(n),
"age": ages,
"purchase_amount": amounts,
"session_seconds": session_seconds,
"churn_probability": churn_probs,
})Correlated Fields
The harder problem: generating fields that correlate realistically. High income should correlate with age. Urban users should have zip codes that match their city.
# Correlated income/age using Cholesky decomposition
mean = [45, 75_000] # [age, income]
cov = [
[144, 50_000], # age variance, age-income covariance
[50_000, 625_000_000] # age-income covariance, income variance
]
samples = rng.multivariate_normal(mean, cov, size=n)
ages = samples[:, 0].clip(18, 80).astype(int)
income = samples[:, 1].clip(15_000, 500_000).astype(int)Referential Integrity: The Hard Problem
Individual table generation is easy. Generating a consistent multi-table dataset — where FK relationships hold, cardinalities are realistic, and no orphan records exist — is the real challenge.
Strategy 1: Generate Top-Down
Generate parent records first, then assign FK values from the parent pool:
import random
from faker import Faker
fake = Faker()
# Generate organizations
orgs = [{"id": i, "name": fake.company()} for i in range(100)]
# Generate users (each belongs to one org)
users = [
{
"id": i,
"org_id": random.choice(orgs)["id"],
"email": fake.email(),
"name": fake.name(),
}
for i in range(1_000)
]
# Generate orders (each belongs to one user)
orders = [
{
"id": i,
"user_id": random.choice(users)["id"],
"amount": round(random.lognormvariate(3.5, 1.2), 2),
"created_at": fake.date_time_this_year(),
}
for i in range(5_000)
]Strategy 2: Realistic Cardinality with Weighted Assignment
Real FK distributions are not uniform. Most orders belong to power users; most users have zero or one orders. Weight the FK assignment:
import numpy as np
# Users with Pareto-distributed order counts
# Top 20% of users generate 80% of orders
user_ids = [u["id"] for u in users]
user_weights = np.random.pareto(a=1.5, size=len(user_ids))
user_weights /= user_weights.sum()
orders = [
{
"id": i,
"user_id": np.random.choice(user_ids, p=user_weights),
"amount": round(np.random.lognormal(3.5, 1.2), 2),
}
for i in range(10_000)
]Strategy 3: Schema-Aware Generation with SDV
The Synthetic Data Vault (SDV) library learns the statistical properties of your real data and generates new data that matches them — including cross-table relationships:
pip install sdvfrom sdv.datasets.demo import load_demo
from sdv.multi_table import HMASynthesizer
from sdv.metadata import MultiTableMetadata
# Define your schema
metadata = MultiTableMetadata()
metadata.detect_from_dataframes(data={
"users": users_df,
"orders": orders_df,
"order_items": items_df,
})
# Define relationships
metadata.update_relationship(
parent_table_name="users",
child_table_name="orders",
parent_primary_key="id",
child_foreign_key="user_id",
)
# Train on real data
synthesizer = HMASynthesizer(metadata)
synthesizer.fit(data={"users": users_df, "orders": orders_df, "order_items": items_df})
# Generate synthetic data preserving all relationships
synthetic_data = synthesizer.sample(scale=1.0)SDV's HMASynthesizer (Hierarchical Modeling Algorithm) handles multi-table FK relationships natively. The synthetic dataset it generates:
- Has the same column distributions as the original
- Preserves FK integrity across all tables
- Respects cardinality (a user with 10 orders in real data will have ~10 orders in synthetic data)
- Contains no real PII
AI-Powered Synthesis with Gretel
Gretel.ai takes the SDV approach further with Generative Adversarial Networks (GANs) and diffusion models for tabular data. It's particularly good at:
- Learning complex correlations between columns
- Generating rare events at programmable rates
- Producing data that passes statistical tests for resemblance to the original
# Gretel SDK example
from gretel_client import Gretel
gretel = Gretel(api_key="your-api-key")
# Train a synthetic model on your data
model = gretel.submit_train(
"tabular-actgan",
data_source="s3://your-bucket/production-sample.csv",
)
model.wait()
# Generate synthetic records
records = gretel.submit_generate(model.model_id, num_records=100_000)Gretel's ACTGAN model (conditional GAN for tabular data) handles:
- Mixed data types (numeric, categorical, datetime)
- High cardinality categoricals (user IDs, product SKUs)
- Imbalanced distributions (fraud, churn, rare events)
For privacy-sensitive use cases, Gretel offers differentially-private synthesis that provides mathematical guarantees that individual records can't be recovered from the synthetic data.
Generating Time Series Test Data
Time series data has additional constraints: ordering, trends, seasonality, and autocorrelation. Naive random generation produces data that looks nothing like real metrics:
import numpy as np
import pandas as pd
def generate_timeseries(
start: str,
periods: int,
freq: str = "1H",
base_value: float = 100,
trend: float = 0.01,
seasonality_period: int = 24,
noise_std: float = 5.0,
anomaly_rate: float = 0.01,
) -> pd.Series:
timestamps = pd.date_range(start=start, periods=periods, freq=freq)
t = np.arange(periods)
# Trend component
trend_component = base_value + trend * t
# Seasonal component (sinusoidal)
seasonal_component = 20 * np.sin(2 * np.pi * t / seasonality_period)
# Noise
noise = np.random.normal(0, noise_std, periods)
# Anomalies
anomalies = np.zeros(periods)
anomaly_indices = np.random.choice(periods, int(periods * anomaly_rate), replace=False)
anomalies[anomaly_indices] = np.random.normal(0, 50, len(anomaly_indices))
values = trend_component + seasonal_component + noise + anomalies
return pd.Series(values, index=timestamps, name="metric")
# Generate 30 days of hourly server response times
response_times = generate_timeseries(
start="2024-01-01",
periods=720, # 30 days × 24 hours
base_value=150, # 150ms baseline
trend=0.05,
seasonality_period=24,
noise_std=20,
anomaly_rate=0.02,
)Testing the Synthetic Data Itself
Generated data needs validation before it's useful for testing. Key checks:
Schema Validity
import pandera as pa
schema = pa.DataFrameSchema({
"user_id": pa.Column(int, pa.Check.gt(0)),
"email": pa.Column(str, pa.Check(lambda s: s.str.contains("@").all())),
"age": pa.Column(int, pa.Check.between(18, 120)),
"amount": pa.Column(float, pa.Check.ge(0)),
})
schema.validate(synthetic_df)Referential Integrity
def check_fk_integrity(parent_ids: set, child_fk_values: pd.Series) -> bool:
orphans = set(child_fk_values) - parent_ids
if orphans:
print(f"Orphan FK values: {orphans}")
return False
return True
user_ids = set(users_df["id"])
check_fk_integrity(user_ids, orders_df["user_id"])Statistical Resemblance
from scipy import stats
def compare_distributions(real: pd.Series, synthetic: pd.Series) -> float:
"""KS test: p-value > 0.05 means distributions are not significantly different"""
ks_stat, p_value = stats.ks_2samp(real.dropna(), synthetic.dropna())
return p_value
p = compare_distributions(real_df["age"], synthetic_df["age"])
assert p > 0.05, f"Synthetic age distribution differs significantly (p={p:.4f})"Tool Comparison
| Tool | Language | Best For | Handles FK? | AI-Powered? |
|---|---|---|---|---|
| Faker | Python/JS/Ruby | Field-level generation | No | No |
| Mimesis | Python | High-volume generation | No | No |
| factory_boy | Python | ORM-integrated factories | Yes (SubFactory) | No |
| FactoryBot | Ruby | Rails factories | Yes (associations) | No |
| SDV | Python | Statistical fidelity | Yes (multi-table) | Yes (ML) |
| Gretel | Python/API | Privacy-preserving synthesis | Yes | Yes (GAN/diffusion) |
| Mostly AI | API | Enterprise synthesis | Yes | Yes |
| CTGAN | Python | Single-table GAN synthesis | No | Yes |
When to Use Each Approach
- Faker/Mimesis: Simple schemas, unit tests, CI fixtures. Fast to set up, zero learning curve.
- Factory libraries: When you need test data tightly coupled to ORM models. Best for application-level tests.
- SDV: When you need statistically realistic multi-table data and have a sample of real data to learn from.
- Gretel/Mostly AI: Enterprise use cases where differential privacy guarantees are required, or where statistical fidelity must be formally documented.
Conclusion
Synthetic data generation has a tool for every scale. Start with Faker for most test data needs. Move to SDV when you need multi-table relational integrity with realistic distributions. Reach for Gretel when you need formal privacy guarantees or highly complex statistical fidelity.
The common thread: investing in a proper synthetic data pipeline pays back in faster tests, safer staging environments, and the ability to generate edge cases on demand rather than waiting for them to appear in production.