Lorem ipsum reads fake; production data reads like a lawsuit#
Point a demo at a fresh database and it looks like a wireframe: "Lorem ipsum" in every cell, User 1 through User 5, test@test.com on repeat. Point it at a copy of production and it looks great until legal asks why a real customer's invoices are playing on your landing page. Both failures cost you the demo. The first says the product is a toy; the second is a data-protection incident with your logo on it.
The middle path is synthetic data that is coherent and safe: rows that read like they came from a real account with real history, generated deterministically so every render shows the same believable world, carrying no information that traces back to a person. For a demo video the bar is higher than for a test fixture, because the camera lingers. A unit test never notices that a user's last login predates their signup; a viewer watching a 30-second walkthrough at full resolution does. Demo data has to survive being stared at.
This is the data half of a reproducible render; the demo environment and seed pipeline is the other half, and regenerating the whole demo from a spec only works when the state behind the UI is as deterministic as the script in front of it. A recorder like aidemo (ours; it films a real browser from an agent-authored storyboard and has no GUI timeline editor) captures whatever the frame shows and nothing you left out, which puts the realism and the safety of the data entirely upstream of the camera.
Five axes that separate believable demo data from noise#
Realism is not one property. A dataset can be plausible on one axis and obviously synthetic on another, and the weakest axis is the one the viewer spots. These five are ranked by how loudly they betray a fake when they break:
| Axis | The tell when it fails | How to generate it right |
|---|---|---|
| Referential integrity | An order with no customer, an avatar that 404s, a comment on a deleted post | Insert parents before children and resolve every foreign key to a row that exists |
| Temporal coherence | "Last active" before "joined", every timestamp identical, a trial that expires in 1970 | Derive all of an entity's dates from one anchor date, then offset forward |
| Distributional shape | Every user has exactly three orders, all prices end in .00, no empty states | Sample counts and amounts from a weighted range with a long tail, not a constant |
| Identity coherence | "Wei Chen" at a Munich address with a name badge in Cyrillic | Draw a whole persona from one locale, then derive name, email, avatar, and city from it |
| Volume to frame | Three rows in a table the UI paginates at fifty; a dashboard of zeros | Seed enough rows to fill the exact viewport the demo records |
Referential integrity leads because it is the one a generator can enforce for you. A schema-aware tool such as SeedBase reads your Prisma, Django, or SQL schema and guarantees that declared foreign keys are resolved automatically, with every reference pointing to a real row6. Flat generators that emit one table at a time cannot promise that, and you end up with a user_id that points at nobody.
The last axis is the one demo authors forget. A form-filling test is content with three rows. A demo that pans across a table wants that table full, and it wants the shape the product shows in real accounts: a couple of pinned items at the top, a long scroll below, and one empty state you kept on purpose to show the zero-data screen.
Seed it, or the demo re-rolls on every render#
Random data that changes every run makes a demo unreproducible. Caption timing drifts, zoom targets move, and a golden-file check can never go green. Faker-class libraries fix this with a seed. In Faker.js, faker.seed(42) pins the sequence: seed, generate, reseed with the same number, and you get identical output, because generated values are dependent on both the seed and the number of calls made since it was set1. Python's Faker behaves the same way through Faker.seed(), seeding the shared generator all instances draw from4.
Two traps hide inside that guarantee.
The first is relative dates. faker.date.past(), date.recent(), and UUID v7 anchor on "today," so a seeded run still yields a different timeline tomorrow, and the trial that ended "three days ago" keeps sliding. Faker.js closes this with a pinned reference date: faker.setDefaultRefDate('2026-01-01T00:00:00.000Z') makes relative dates deterministic too2. It is the data-side version of the same clock-freezing you do in the browser for deterministic replay.
The second is version drift. A seed reproduces the same result only when the same methods are called on the same version of the library, and the maintainers warn that results are not guaranteed to be consistent across patch versions as the underlying datasets are updated4. If your demo's data is load-bearing, pin the generator version in your lockfile exactly as you pin the app.
import { faker } from '@faker-js/faker';
faker.seed(42);
faker.setDefaultRefDate('2026-01-01T00:00:00.000Z');
// same 200 users, same join dates, on every render
const users = faker.helpers.multiple(makeUser, { count: 200 });
Making the pieces agree: names, avatars, timelines, foreign keys#
The difference between "fake data" and "a believable account" is that the fields agree with one another. A real user named Priya Nair has an email containing "priya," an avatar of a South Asian woman, a signup date before her first order, and invoice lines that sum to the total shown. Generate each field independently and every one is individually plausible while the row as a whole is nonsense.
The fix is to generate the persona once and derive the rest. Draw a locale, take the name from it, then build the email from the name, the avatar from the same locale and gender, and the city from the same country. Faker.js ships over 70 locales for precisely this, producing names, addresses, and phone numbers that belong together3; Python's Faker exposes a comparable localized set. Thread one locale through the whole entity instead of re-rolling it per field and the "Wei Chen in Munich" giveaway disappears.
Relationships need the same discipline one level up. Timelines are coherent when every date on an entity is an offset from a single anchor: pick joined_at, then set last_login = joined_at + a few days and trial_ends = joined_at + 14 days. Money is coherent when the invoice total is the sum of its line items, not an independent random number. Foreign keys are coherent when children are generated after parents, from the pool of IDs that actually exist, which is the property SeedBase describes as behaving like a database where every foreign key resolves6. Coherence at the model level is also what makes per-prospect personalization safe to automate: if the data model holds together, swapping in one account's parameters cannot produce an impossible row.
Scrubbing production down to demo-safe, in order#
Sometimes you genuinely need production's shape, the real distribution of a table nobody could invent from scratch. Copying it wholesale is the mistake. Work down this order and stop as soon as synthesis is enough:
- Prefer synthesis. If a seeded generator can produce the shape you need, use it and skip the rest. Data that never contained a person cannot leak one.
- Classify columns before touching them. Direct identifiers (name, email, phone), quasi-identifiers (birth date, ZIP, job title, employer), sensitive fields, and free text each demand different handling.
- Mask direct identifiers with format-preserving fakes, consistently. Replace real values with fakes that keep the type and shape, and map each input to the same output every time so joins across tables still line up. Tools that auto-detect PII and keep formats and foreign keys intact do this per run and emit a privacy report6.
- Generalize the quasi-identifiers. A nameless row can still single someone out. GDPR's test is "all the means reasonably likely to be used, such as singling out"5; a birth date plus a ZIP plus a job title is frequently unique. Round dates to the month, truncate ZIPs, bucket the rare titles.
- Scrub free text last and hardest. Support tickets, notes, and audit logs quote names, emails, and card numbers in prose, and a column-level mask never sees them.
- Know which side of the line you landed on. Masking you can reverse with a key is pseudonymisation, and pseudonymised data should be considered information on an identifiable natural person, so it remains personal data and stays in scope5. Only anonymisation, where the data subject is no longer identifiable, takes the data out of scope. A demo you publish has to clear the anonymisation bar, not the pseudonymisation one.
The honest default is to synthesize and never scrub. Scrubbing is a control you reach for when you must; synthesis is the absence of the risk. That is the whole case for a seeded generator over a sanitized dump: one starts from zero personal data, the other spends its life trying to remove the last trace of it.
Sources#
- Faker.js — faker.seed() API reference
- Faker.js — Usage guide (seeding and reference dates)
- faker-js/faker — README (over 70 locales)
- Python Faker — documentation (seeding and reproducibility)
- GDPR Recital 26 — anonymous information and identifiability
- SeedBase — FK-consistent synthetic test data generator
FAQ#
How do I generate realistic test data for a product demo?#
Start from your schema, not from production. A seeded Faker-class generator produces names, addresses, and dates that read as real, and a schema-aware tool resolves foreign keys so every relationship holds. Generate each entity as a coherent persona, with name, email, avatar, and city drawn from one locale, rather than filling fields independently. Seed enough rows to fill the exact view the demo records, including one empty state if the product has one.
Is it safe to use real customer data in a demo?#
No. A demo can be published, embedded, or screen-recorded, so any real customer data in it is a disclosure waiting to happen. Even data with names stripped can single a person out through a birth date, ZIP, and job title, which GDPR Recital 26 treats as identifying. Synthesize the data instead, or, if you must start from production, anonymise it irreversibly before it reaches the frame.
How do I make Faker produce the same data every time?#
Set a seed: faker.seed(42) in Faker.js or Faker.seed(42) in Python fixes the sequence for a given number of calls. Relative-date methods like date.past() ignore the seed because they anchor on today, so pin a reference date with setDefaultRefDate as well. Reproducibility only holds within one library version, so lock the Faker version in your lockfile the way you lock the app.



