Generating Synthetic Test Data With LLMs in October 2026: Start From a Schema, Validate Every Row, and Keep Real Personal Data Out
Every team needs realistic test data: sample customers for a staging database, support tickets to exercise a classifier, messy addresses to stress a form, or a few hundred fake orders for a demo. Copying production data is the fastest option and usually the wrong one, because it drags real personal information into places with weaker access controls. Hand-writing fixtures is safe but slow, and the results tend to be too clean to catch real bugs.
Large language models sit in a useful middle ground. They are good at producing varied, plausible text and records on demand. They are also good at producing data that looks right and quietly breaks your rules: duplicate IDs, impossible dates, totals that do not add up, or a "fake" name and email that turn out to belong to someone real. This guide walks through a workflow that keeps the speed and removes most of the risk.
Decide what the data is for before you generate anything
"Make me some test data" produces generic output. The purpose changes almost every later decision, so write it down first:
- Functional tests and staging databases need data that satisfies every constraint your schema and business rules enforce, plus a deliberate set of edge cases.
- Demos and screenshots need data that reads naturally and looks good, and correctness matters less than tone and consistency.
- Evaluation sets for an AI feature (for example, test inputs for a ticket classifier) need realistic variety and a known correct answer for each example. The labels matter more than the surface text.
- Load and performance tests need volume and realistic size distributions, which is a job for ordinary code, not a model.
That last point is worth stressing. If you need ten million rows, do not ask a model for ten million rows. Use the model to design templates, vocabularies, and edge cases, then let a deterministic generator multiply them.
Split the work: code owns structure, the model owns texture
The most reliable pattern is to let normal code generate everything that has a right answer and ask the model only for the parts that need human-sounding variety.
Code should own primary keys, foreign keys, timestamps, numeric amounts, enums, and anything derived from other fields, such as an order total computed from line items. Libraries like Faker (available for Python, JavaScript, and other languages) already cover names, addresses, and phone formats, and seeded random generators make the output repeatable.
The model should own the free text and the realistic combinations: a product review that matches its star rating, a support ticket that mentions the product the customer actually bought, a shipping note with a believable typo, or a job title that fits the company size.
In practice this means you generate a skeleton row in code, then pass the skeleton to the model and ask it to fill in only the text fields. For example:
skeleton = {
"ticket_id": "T-000412",
"customer_plan": "starter",
"product": "invoice export",
"created_at": "2026-09-14T10:22:00Z",
"true_category": "billing",
"sentiment": "frustrated"
}
# Ask the model: write "subject" and "body" for this ticket.
# The text must fit the category, product, and sentiment.
# Do not invent order numbers, names, or email addresses.
Because the label (true_category) came from your code, you know the right answer for every example. That is exactly what you want for an evaluation set, and it avoids the common mistake of asking the model to write a ticket and also tell you what category it belongs to.
Request structured output against a schema
When the model does produce records, ask for JSON that matches an explicit schema, and use your provider's structured output or JSON schema mode if it has one. Keep each request small, roughly 10 to 50 records, so one bad response does not cost you much and you can retry cheaply.
A useful prompt includes:
- The exact field list with types and allowed values.
- The rules that matter: "rating is an integer from 1 to 5, and review tone must match the rating."
- A target mix: "about 20 percent of reviews should be 1 or 2 stars, and at least two should mention shipping."
- Explicit instructions on what not to produce: real company names, real people, URLs, or anything that looks like a credential.
Even with schema mode, treat the response as untrusted input. Schema enforcement guarantees shape, not meaning.
Validate every row in code
This is the step that separates useful synthetic data from a liability. Run every generated record through the same checks your application would apply, and then some:
- Schema and type checks with a validator such as Pydantic, Zod, or JSON Schema.
- Business rules: end dates after start dates, quantities above zero, totals equal to the sum of line items, states that match the postal code format you expect.
- Referential integrity: every foreign key points at a row that exists.
- Consistency checks: a 1-star review should not say "absolutely love it." A cheap classifier or a second model pass can flag mismatches for you to drop.
- Uniqueness: no duplicate IDs, and no near-identical text. Models repeat themselves more than you expect when you ask for many similar items, so compare normalized text and discard close copies.
Do not repair bad rows by hand. Log the failure reason, drop the row, and regenerate. If one rule fails often, that is a signal to fix the prompt or move that field into code.
Keep real personal data out, in both directions
There are two separate privacy risks here, and teams often only think about one.
The first is what you send to the model. If you paste a sample of production rows into the prompt "so it knows what the data looks like," you have just sent real customer data to a third party. Describe the shape and distributions instead: field names, value ranges, rough proportions, and a couple of hand-written examples that you know are fake.
The second is what comes back. A model can produce a real person's name, a working-looking phone number, or a real company's domain simply because those are common in its training data. A few habits reduce this:
- Generate names, emails, and phone numbers in code, not in the model.
- Use reserved values where they exist. The domains
example.com,example.org, andexample.netare reserved for documentation by RFC 2606. In the United States, phone numbers from 555-0100 through 555-0199 are set aside for fictional use. - Scan the generated text with the same PII detection you use elsewhere and drop rows that trigger it.
- Never use credit card numbers other than the official test numbers your payment provider publishes.
Plan your edge cases on purpose
Left alone, a model generates the typical case: tidy English names, ordinary addresses, polite messages. Real bugs live at the edges, so list them yourself and generate them as a separate, labeled batch. Common ones include:
- Very long and very short values, including empty strings where the schema allows them.
- Names with apostrophes, hyphens, accents, and non-Latin scripts.
- Emoji, right-to-left text, and mixed languages in free-text fields.
- Dates near month ends, leap days, and daylight saving transitions.
- Strings that look like markup or code, such as
<script>or a stray SQL quote, to confirm your escaping works.
Tag these rows (for example, "edge_case": "rtl_text") so a failing test tells you immediately which kind of input broke.
Make it repeatable
Test data that changes every time you run the generator makes failures hard to reproduce. Model output is not reliably deterministic even with a fixed seed parameter, so do not regenerate on every CI run. Instead:
- Generate once, validate, and commit the resulting files (or store them as a versioned artifact).
- Save the prompt, model name, and generator script next to the data, so anyone can see how it was made.
- Use a fixed random seed for everything your code generates.
- Regenerate deliberately when the schema changes, and review the diff like any other code change.
Check the whole dataset, not just individual rows
Rows can each pass validation while the set as a whole is unrealistic. Before you rely on it, look at simple summaries: the distribution of categories, ratings, and countries; the range of text lengths; the share of edge cases. Compare those against what you asked for. If you requested 20 percent negative reviews and got 4 percent, adjust and regenerate.
For evaluation sets in particular, read a random sample by hand. Twenty minutes spent reading 50 examples catches problems that no automated check will, such as every "angry" ticket using the same three phrases.
A short checklist
- Write down what the data is for and how many rows you actually need.
- Generate IDs, numbers, dates, labels, and personal fields in seeded code.
- Use the model only for free text and realistic combinations, in small batches against a schema.
- Validate every row, drop failures, and fix the prompt when one rule keeps failing.
- Never paste production rows into a prompt, and scan the output for PII.
- Add a labeled batch of edge cases.
- Commit the validated data with its prompt and script, and regenerate only on purpose.
Used this way, a language model becomes a fast source of realistic variety rather than a source of mystery bugs. The model writes the words, your code guarantees the rules, and nobody's real data ends up in a staging database by accident.
Comments
Post a Comment