BXUP Track progress & test yourself — free
QA & Testing · Playbook · Core · ~5 min read
QA & TESTING · PLAYBOOK · CORE

Generate Schema-Constrained Synthetic Test Data

LLMs generate realistic-looking test data effortlessly — and violate your schema, your foreign keys, and your business rules just as effortlessly. Schema-constrained generation flips the trust model: the model proposes rows, Pydantic and the database dispose, and a validation loop feeds every rejection back until the dataset is provably conformant.

Tier: Core · Workflow area: Test data & environments

This playbook builds a repeatable data factory: LLM generation for realism and edge-case intent, hard validation against Pydantic models and relational constraints, deterministic tools (Faker/SDV) for bulk, and a versioned output that seeds environments identically every run.

USE IT WHEN

Realism and structure both matter

  • Tests need plausible, varied data — names across locales, believable orders, coherent histories — not user1@test.com × 500.
  • The schema has real teeth: foreign keys, enums, cross-field rules (end_date > start_date) that naive generators trip over.
  • Production data is off-limits (DPDP/GDPR, client policy) and masking real extracts is not on the table.
SKIP IT WHEN

Cheaper tools already win

  • Pure bulk volume with no realism requirement — Faker alone, or SQL generate_series, is faster and free.
  • You need statistical fidelity to a real distribution for performance testing — that is SDV-class synthesis trained on (masked) real data, with LLMs at most naming things.
  • One-off fixture of ten rows — write it by hand; a factory needs a payback period.

Prerequisites

  • The schema expressed as code: Pydantic models (or JSON Schema) mirroring your tables, including the constraints that matter.
  • An LLM API with structured output (or any chat model plus strict JSON prompting); Ollama works for the local variant.
  • Faker for bulk filler; a scratch database to prove referential integrity by actually loading.
  • A repo location for generator scripts and versioned datasets — data is code here.

The workflow

  1. Write the schema as executable truth first. Every rule you want enforced must live in the Pydantic model — the LLM prompt is documentation, the validator is law:

    class Customer(BaseModel):
        id: UUID
        email: EmailStr
        full_name: str = Field(min_length=1, max_length=120)
        locale: Literal["en-IN", "hi-IN", "ta-IN", "en-US", "de-DE"]
        created_at: datetime
    
    class Order(BaseModel):
        id: UUID
        customer_id: UUID            # FK enforced at load time
        status: Literal["pending", "paid", "shipped", "cancelled", "refunded"]
        total: Decimal = Field(ge=0, max_digits=10, decimal_places=2)
        placed_at: datetime
    
        @model_validator(mode="after")
        def paid_before_shipped(self): ...   # cross-field business rule
    
  2. Prompt for slices, not the world — with the schema in context. Generate in themed batches so intent stays reviewable:

    Generate 30 customers as a JSON array conforming EXACTLY to this Pydantic
    model (attached). Requirements:
    - Locale mix: 40% en-IN, 15% hi-IN, 15% ta-IN, 20% en-US, 10% de-DE,
      with names plausible for each locale (scripts included for hi-IN/ta-IN).
    - Include deliberate edge members: 1 name at exactly 120 chars, 1 single-char
      name, 2 emails with plus-addressing, 1 with an IDN domain.
    - created_at spread over the last 3 years, none in the future.
    Output raw JSON only — no markdown, no commentary.
    

    The model's actual contribution is realism and edge-case intent — locale-plausible names in the right scripts are exactly what Faker does poorly and tests involving rendering, sorting, and search actually need.

  3. Validate hard; loop rejections back verbatim. The factory's core is a while-loop, not a prayer:

    for attempt in range(3):
        rows, errors = validate_batch(raw_json, Customer)
        if not errors: break
        raw_json = llm(f"These {len(errors)} records failed validation. "
                       f"Errors: {errors_as_json}. Regenerate ONLY the failing "
                       f"records, corrected. Same rules, raw JSON only.")
    

    Typical loop output — this is the flip working as designed:

    batch customers-03: 30 generated, 27 valid
    rejected: idx 7 email 'anita@localhost' not valid EmailStr;
              idx 12 created_at 2027-01-03 in future; idx 22 locale 'en-GB' not allowed
    retry 1: 3 regenerated, 3 valid -> batch complete (30/30)
    
  4. Enforce referential integrity by loading, not by trusting. Generate parents before children, hand the child-generation prompt the actual parent IDs, then prove the whole graph in the scratch DB:

    psql testdata -f load_customers.sql -f load_orders.sql   # FKs on, constraints on
    # any FK violation = generator bug; fix the pipeline, never hand-edit the data
    
  5. Split labor: LLM for the interesting 10%, Faker for the bulk. Ten thousand background orders come from Faker templates seeded from the LLM's realistic exemplars; the LLM hand-crafts the personas, edge members, and scenario-critical rows your tests assert against. This keeps cost near zero and the interesting rows curated.

  6. Version the factory and pin the seed. Scripts, prompts, schema, and either the generated dataset or a deterministic regeneration path (fixed Faker seed + cached LLM outputs) live in git. Tests reference dataset versions (testdata-v7), so a failure on Tuesday is reproducible on Thursday. Schema migrations break the factory loudly — a Pydantic model that no longer matches the DB fails at load, which is the drift alarm doing its job.

Key Takeaway:

Never trust, always verify — the LLM is a fluent intern with no memory of your constraints. Everything it emits passes through Pydantic and a real constraint-checking load before any test sees it. The validation loop is not overhead; it is the product.

Variations

  • Structured-output APIs: where the provider supports JSON-schema-enforced responses, syntax errors drop sharply — keep the semantic validators anyway; enforced shape still permits future dates and absurd totals.
  • SDV/statistical hybrid: train SDV on a masked production sample for volume with true distributions; use the LLM only to post-name entities and inject edge personas.
  • Local model (Ollama): for client-restricted contexts; smaller models fail validation more often, which costs retries, not correctness — the loop absorbs the difference.
  • API-seeding instead of SQL: load through your application's own API to inherit its validation and side effects; slower, but catches "valid in DB, impossible via app" fictions.

Quality check

  • Zero validation debt: final datasets show 100% Pydantic pass and a clean constrained load; rejection stats logged per batch.
  • Edge members present and findable: the deliberately planted extremes (max-length, boundary dates, unicode names) are tagged so tests can target them.
  • Determinism: regenerating testdata-v7 from the repo yields identical data (seeds pinned, LLM outputs cached).
  • No real-person leakage: spot-check that generated identities are not memorized real people — emails at reserved domains (example.com, .test), phone numbers in reserved ranges.
  • Distribution sanity: quick histogram of key fields (locales, statuses, totals) matches the spec you prompted — models drift toward bland middles.

Pitfalls

  • The all-median dataset. Unprompted, LLMs generate suspiciously pleasant data — everyone's order is ~₹1,499 and named conveniently ASCII. Demand distributions and extremes explicitly; verify with the histogram.
  • Hand-patching rejected rows. The moment someone edits the JSON by hand, determinism and the drift alarm both die. Fix the prompt or the generator; regenerate.
  • FK integrity by coincidence. Letting the model invent customer_ids "matching" the parents works until it doesn't; pass real IDs in, and let the DB load be the proof.
  • Schema in prose only. Rules stated in the prompt but absent from the validator are wishes. If it matters, it is in the Pydantic model.
  • Realistic-but-real data. Models can emit memorized real emails, addresses, phone numbers. Reserved domains and ranges are policy, not garnish — this is also what keeps the dataset DPDP-clean by construction.

Restricted-environment variant

Fully synthetic generation is itself the privacy-safe path — no production extract is ever prompted, so DPDP/GDPR exposure is designed out rather than mitigated. On client engagements: run generation on an approved endpoint or local Ollama, keep the client's schema files inside their repo boundary, and document the factory (schema-as-code, validation loop, reserved identifier ranges) as the evidence pack for their data-protection review. If distributions must mirror client production, mask first (see the PII-masking playbook), train SDV on the masked sample, and keep the LLM away from the extract entirely.

Success criteria

  • A committed, versioned factory that any teammate regenerates identically with one command.
  • 100% schema and constraint conformance, with rejection-loop logs proving the validation actually fires.
  • Tests reference stable dataset versions; environment seeding stops being a Slack request and becomes a script.
  • Realism where it pays (locales, scripts, edge personas) and zero real personal data by construction.
Pro Tip

Add one deliberately invalid dataset to the factory — rows that violate each constraint in a known way — and keep it out of the seed path. It is the perfect fixture for testing your own validators, ETL error handling, and API 4xx paths, and the factory that builds good data builds its evil twin for free.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in