Synthetic Test Data Generation: AI, Statistics, and Referential Integrity

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:

  1. 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.
  2. 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.
  3. 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 sdv
from 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.

Read more

Start now free