Generating realistic, PII-safe test data with AI
Your staging database has forty users, all called "Test User 1" through "Test User 40", all with the address "123 Main St". Then a bug arrives from production: the invoice PDF breaks for a customer whose company name is 94 characters long and contains an ampersand.
You could not have found that in staging. Your data was too clean, too short, and too English.
AI models are good at generating messy, plausible data quickly. They are also very good at accidentally producing something that looks like a real person. This guide covers both halves.
Short version
- PII means personally identifiable information: anything that can identify a real person, such as a name with an email or an address.
- Never paste real customer records into a model, in any form, for any reason.
- Ask for data that is realistic in shape, not realistic in identity. Long names, unicode, awkward punctuation.
- Generate a script that produces data, not the data itself. Scripts can be re-run and reviewed.
- Use a fixed seed so runs are repeatable and failures reproduce.
- Referential integrity is the hard part. Generate parents first, then children that reference real IDs.
The compliance line
Start here, because everything else depends on it.
Do not send real customer data to a model. Not a CSV export "just to get the format". Not a single row. Not with the names removed.
Three reasons, in order of how often they bite teams:
- Removing names does not anonymise data. A postcode, a birth date, and a purchase amount can identify one person. Researchers have shown this repeatedly.
- You lose control of where it goes. Many chat products retain prompts. Some use them to improve the service. Your data processing agreement with your customer almost certainly did not cover that.
- It is usually a breach on its own. Under GDPR and similar laws, sending personal data to a new processor without a legal basis is the violation. Nothing bad has to happen afterwards.
The safe pattern is the reverse of what people try first. Do not send data and ask for more like it. Send the schema and ask for a generator.
Bad: "Here are 50 rows from our users table. Generate 500 more like these."
Good: "Here is our users table schema. Write a script that generates 500 realistic rows. No real people."
What "realistic" should mean
Realistic does not mean "looks like a normal customer". It means "looks like the awkward end of your real distribution". Your test data should include the cases that break formatting, validation, and layout.
| Field | Boring test data | Data that finds bugs |
|---|---|---|
| Name | John Smith | Björk Guðmundsdóttir, O'Brien-Sørensen, 名字 |
| Company | Acme Inc | Smith & Sons (Holdings) Ltd., trailing space |
| [email protected] | [email protected], 64-char local part | |
| Amount | 100.00 | 0.00, -25.50, 999999999.99, 0.005 |
| Address | 123 Main St | Missing line 2, 5-line address, no postcode |
| Phone | 5551234567 | +49 (0)30 1234-5678, extension, empty |
| Date | 2026-01-15 | 29 Feb 2024, 1970-01-01, year 2999 |
| Text field | Hello | 5000 chars, emoji, <script>, newlines |
Ask for this explicitly. Left alone, a model produces the boring column, because that is what most text on the internet looks like.
Prompt pattern 1: the generator script
The core prompt. Adapt the schema to yours.
Write a Python script that generates synthetic test data for this schema. Use the Faker library and a fixed random seed of 42.
Table: users
- id: uuid
- full_name: text, 1-120 chars
- email: text, unique
- country_code: 2-letter ISO
- created_at: timestamp
- marketing_opt_in: boolean
Requirements:
- 500 rows.
- All emails must use the example.com, example.org, or example.net domains only.
- No real people. Names must be assembled from a fake-name library, never copied from public figures.
- Include deliberately awkward values: 5% of names must be over 80 characters, 10% must contain non-ASCII characters, 3% must contain an apostrophe or hyphen.
- 2% of rows should have created_at in the future.
- Output as a CSV file and print the row count.
Three details in that prompt are doing real work:
- Reserved domains.
example.com,example.org, andexample.netexist precisely for this. They cannot receive mail, so a leaked test run cannot email a stranger. - The fixed seed. A seed is the starting number for a random generator. Same seed, same data, every time. Without it, a failing test cannot be reproduced tomorrow.
- The percentages. They force the awkward cases into the set instead of leaving them to chance.
Prompt pattern 2: referential integrity
Referential integrity means every reference points at something that exists. An order row with user_id = 7 needs a user with id 7. This is where naive generation falls apart: ask a model for 500 orders and it will invent 500 user IDs that match nothing.
Generate in dependency order, and make each step read the previous output.
- Generate parents first: users, then products.
- Write the parent IDs to a file.
- Generate children by sampling from that file, never by inventing IDs.
- Generate join tables last.
The prompt:
Extend the script. After generating users.csv and products.csv, generate orders.csv where:
- Every user_id is sampled from the ids actually present in users.csv.
- Every order has 1-5 line items, each referencing a product_id from products.csv.
- order.total must equal the sum of its line item amounts, to 2 decimal places.
- order.created_at must be after the referencing user's created_at.
- 20% of users have zero orders. 3 users have more than 200 orders.
Add an assertion at the end of the script that fails loudly if any foreign key does not resolve.
That last line matters. Broken test data produces confusing test failures, and you can lose an afternoon to a bug that was never in the product.
The distribution requirements matter too. Users with 200+ orders are how you find the pagination bug and the report that times out. Users with zero orders are how you find the empty-state crash.
Prompt pattern 3: targeted edge-case rows
Bulk data covers the volume. You also need a small, hand-checked set of nasty rows.
Generate 25 individual test records designed to break a checkout form. For each, give the field values and a one-line note on what it tests.
Cover at minimum: maximum-length inputs, unicode and right-to-left text, SQL-like and HTML-like strings in text fields, whitespace-only values, numeric fields at zero and negative, and a name that is exactly one character.
Format as a markdown table.
Keep these 25 in version control as a fixture file. They become a regression set. Every time a formatting bug reaches production, add the shape of that value to the file.
Where teams go wrong
Asking the model for the rows directly. You get 500 rows in chat, paste them in, and six weeks later nobody can regenerate them or explain what they cover. Ask for the script instead. The script is reviewable, re-runnable, and lives in your repository.
Forgetting the seed. Without a fixed seed, your Tuesday failure does not reproduce on Wednesday. Set it, log it, and put it in the test output.
Generating data that is all valid. If every row passes validation, you never test the error path. Aim for a small, deliberate share of invalid rows in your bulk set, clearly marked.
Letting it near production. Synthetic data belongs in local and staging environments. Guard it: give test users a recognisable marker such as an @example.com domain, and add a check that refuses to run the seeding script when the database URL points at production.
Never checking what came out. Read fifty rows by hand the first time. Models sometimes produce a "realistic" value that is a real public figure's name and address. Look for it and remove it.
A quick safety checklist
Run through this before any generated dataset lands in a shared environment.
- No real customer record was ever pasted into a prompt
- All emails use example.com, example.org, or example.net
- All phone numbers use a reserved test range
- Payment data uses the processor's documented test card numbers only
- No real public figures' names in the output
- Fixed seed set and recorded
- Foreign key assertion passes
- Script refuses to run against a production database URL
- Dataset regenerable from the repository by anyone on the team
If any box is unchecked, the dataset is not ready to share.
What this changes day to day
Once the generator exists, the value compounds. A new tester builds a full local environment in one command. A production bug is reproduced by adding one row shape to the fixture file.
And when the invoice PDF breaks on a 94-character company name with an ampersand, you will have found it in staging, because that row was in your data by design.
Frequently asked questions
Can I use anonymised production data instead? Only with real anonymisation, done by a documented process, signed off by whoever owns data protection at your company. Deleting the name column is not anonymisation. If your team cannot describe the technique in one sentence, assume it is not safe.
Is a library like Faker enough on its own? Often yes, for names and addresses. The model adds value in writing the relationships, the distributions, and the awkward cases around it. Use both: the model writes the script, the library supplies the values.
How much test data do I actually need? Enough to hit your pagination, sorting, and reporting limits. If your table shows 25 rows per page, a few hundred rows exercises far more code than fifty. Volume tests are separate and usually need a different tool.
What about test data for payments? Use only the test card numbers published by your payment processor, such as the documented card that always triggers a decline. Never generate card numbers yourself, even fake-looking ones, and never store them anywhere.
How do I keep generated data from going stale? Regenerate it in CI on a schedule, and treat the generator script as code: reviewed, versioned, and updated whenever the schema changes. If a migration breaks the script, that is useful early warning.




