Development

Seeding a Staging Database with Fixtures and Anonymized Data

Antonio Evans8 min read

A database cylinder labeled 'Staging' with several rows of data flowing into it from a funnel labeled 'Seed Script'.
Key points

Key points

  1. 1Define tenants, roles, permissions and users in versioned fixture files, and let one seed script turn them into database rows.
  2. 2Generate every person record from a stable key (tenant plus email or ID) so reruns produce the same pseudonyms and the same fake emails.
  3. 3Load fixtures inside a transaction that truncates child tables first and parents last, so foreign keys never block a refresh.
  4. 4Record a checksum of the fixture set in a seed ledger table, and use it to skip, warn or force a reload when the files change.

Why staging data drifts, and what a seed script fixes

A staging environment only earns its keep if it behaves like production: same backing services, same configuration shape, same data shape. The Twelve-Factor guidance on dev/prod parity is explicit that all deploys should use the same type and version of each backing service, because tiny incompatibilities surface as failures that only appear after release the Twelve-Factor App. A staging database restored from a stale dump quietly breaks that parity: the schema moves forward, the copy does not, and restores fail on a column added after the dump was taken staging without production data.

Two failure modes dominate. The first is copy-based: someone dumps production, restores it to staging, and runs a hand-written anonymization script. That script is written once and never fully maintained, so new tables that hold names, addresses or free-text support notes keep real values staging without production data. The second is ad hoc fixtures: a few rows pasted into a seed file during a sprint, then edited by hand on the staging box until nobody knows which state is canonical. Cloud Posse's reference architecture flags exactly this, noting that quality assurance teams want stable data so they can run test scenarios repeatedly Cloud Posse seeding strategy.

A seed script replaces both with something deterministic. The fixtures are code. The script is code. The result is a dataset you can rebuild from scratch on any environment, and a staging box that any teammate can reset to a known state before a demo or a test run.

Model tenants, roles and permissions as declarative fixtures

Start with the entities that are painful to improvise: tenants (or organizations, workspaces, accounts), the role catalog, the permission catalog, the role-to-permission grants, and the users with their tenant memberships and role assignments. Write each as a YAML file under a fixtures/ directory and treat those files as the source of truth.

# fixtures/tenants.yaml
- key: acme
  name: Acme Holdings
  plan: enterprise
  region: us-east
- key: globex
  name: Globex Industries
  plan: team
  region: eu-west

# fixtures/roles.yaml
- key: owner
  permissions: [billing.write, members.write, reports.read]
- key: analyst
  permissions: [reports.read]

# fixtures/users.yaml
- tenant: acme
  email_key: ada
  role: owner
- tenant: acme
  email_key: grace
  role: analyst
- tenant: globex
  email_key: alan
  role: analyst

Three rules keep this stable. First, every fixture row has a key: a short, human-readable identifier that never changes even if the display name does. Second, fixtures reference each other by key, never by database ID, so you can reload into a fresh database and let the IDs be assigned at insert time. Third, permissions live in one catalog file and roles reference permission keys, so role coverage is visible at a glance and you can spot a role that grants nothing.

Aim for coverage rather than volume. You want at least one tenant per plan tier, at least one user per role, and at least one user who belongs to two tenants. That last case catches scoping bugs that single-tenant seed data hides: a query that forgets its tenant filter will return the wrong rows only when a second tenant exists to return.

Fixture concernBad practiceSeed-script practice
IdentityAuto-increment IDs referenced in fixturesStable key per row, IDs assigned at insert
EmailsReal-looking addresses, possibly real peopleDeterministic user_<hash>@example.com addresses
PasswordsThe same plain string in every environmentOne bcrypt hash of a documented test password, loaded from config
Role coverageOne user who is also an adminOne user per role, plus a multi-tenant member
OrderingInsert in whatever orderParents before children, one transaction

Because fixtures are versioned with the code, the tenant and role model in staging tracks the tenant and role model in the application's permission definitions. When a new role ships, its fixture row ships in the same pull request.

Generate stable pseudonyms and fake email addresses

The whole point of anonymized staging data is that the rows look real enough to test with but contain no personal data. GDPR requires data minimization and data protection by design, and "we copied production because it was realistic" satisfies neither staging without production data. Synthetic or generated data avoids the compliance question entirely.

The trap with naive generators is instability: a random fake name changes on every run, so a test that passes today fails tomorrow because the display name differs. The fix is deterministic derivation. Take a stable input, a fixed key, hash it, and use the hash to choose from word lists.

import hashlib

FIRST = ["Ada", "Grace", "Alan", "Edsger", "Barbara", "Donald", "Frances", "Ken"]
LAST = ["Lovelace", "Hopper", "Turing", "Dijkstra", "Liskov", "Knuth", "Allen", "Thompson"]

def stable_index(seed: str, size: int) -> int:
    digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()
    return int(digest[:8], 16) % size

def pseudonym(tenant: str, email_key: str) -> dict:
    seed = f"{tenant}:{email_key}"
    first = FIRST[stable_index(seed + ":first", len(FIRST))]
    last = LAST[stable_index(seed + ":last", len(LAST))]
    local = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
    return {
        "first_name": first,
        "last_name": last,
        "email": f"user_{local}@example.com",
    }

Two properties fall out of this. The same tenant:email_key pair always produces the same name and the same email, so a refresh does not invalidate test expectations. And the email lands on a reserved domain: example.com is set aside for documentation and testing, which means no staging run can accidentally deliver mail to a real person. Cloud Posse's seeding guidance asks for exactly this, anonymized users with invalid email addresses, and warns that a production snapshot makes it possible to send real email to everyone from staging Cloud Posse seeding strategy.

If you need realistic distributions for query planning rather than just a small fixture set, generate at higher volume with the same deterministic function keyed on a row number instead of an email key. Xata's write-up on anonymization points out that synthetic data with uniform distributions can produce artificially good query performance, because real data carries correlations and non-uniform values that shapes estimates anonymization in dev workflows. Deterministic volume generation at least gets you the shape; keep a couple of deliberately skewed tenants (one very large, one tiny) to exercise planning paths.

Handle relationships and foreign keys in the right order

Foreign keys are the usual reason seed scripts fail halfway. The answer is not to disable constraints during the load: that hides bugs and lets you insert fixtures the application could never create. The answer is ordering plus deferral.

Order parents before children: tenants, then roles and permissions, then role-permission grants, then users, then tenant memberships, then role assignments on memberships. If your database supports deferred constraints, declare the foreign keys as deferrable initially deferred and the checks run at commit time rather than statement time. That lets you insert a membership before the user row without violating the constraint, which simplifies cross-referencing fixtures.

For truncation before a reload, delete children first and parents last, or use a single TRUNCATE with a CASCADE that PostgreSQL refuses if any referencing table is not included. The safer pattern is explicit deletion in reverse dependency order, wrapped in one transaction, so a failed load leaves the previous dataset intact rather than a half-empty database.

BEGIN;
DELETE FROM role_assignments;
DELETE FROM memberships;
DELETE FROM users;
DELETE FROM role_permissions;
DELETE FROM roles;
DELETE FROM permissions;
DELETE FROM tenants;
-- inserts follow here
COMMIT;

Wrapping the whole run in a transaction also gives you a clean signal: if the fixture graph is inconsistent (a user referencing a role that does not exist), the seed fails and rolls back instead of leaving staging in a state that mimics a production incident you did not plan for. Staging exists to test in a production-like replica without touching real users, and a half-loaded replica tests nothing Northflank staging guide.

Refresh without drift using a versioned ledger and checksums

The hard part is not the first load, it is the twentieth. Someone edits staging by hand, a fixture file changes, a schema migration lands, and the next refresh behaves differently from the last. The fix is a small ledger table that records what was loaded and when.

CREATE TABLE seed_ledger (
  id            uuid PRIMARY KEY,
  fixtures_hash text NOT NULL,
  seed_version  text NOT NULL,
  applied_at    timestamptz NOT NULL DEFAULT now(),
  applied_by    text NOT NULL,
  row_counts    jsonb NOT NULL
);

The seed script computes a SHA-256 checksum over the sorted list of fixture file paths and their contents, then compares it with the most recent fixtures_hash. If the checksum matches and --force is not passed, the script exits without touching data and prints the ledger row. If it differs, the script reloads inside a transaction and then inserts a new ledger row. Hashes rather than timestamps, because a timestamp of "when the file changed" is ambiguous across branches and machines; a checksum of the content is unambiguous.

Store row_counts too, as a small map of table name to inserted count. That one field turns the ledger into a drift detector: if the ledger says 60 users and the table holds 61, someone inserted a row by hand and the next run will wipe it. Alert on the mismatch rather than silently fixing it, and you will learn who is writing to staging outside the seed.

This pattern pairs well with ephemeral environments. Preview environments for pull requests spin up a fresh stack, seed it, and tear it down on merge, which is the cleanest possible answer to drift because every environment starts from the same fixtures Bunnyshell preview environments. Argo CD exposes the same idea with a pull request generator that creates an environment per PR and destroys it afterward Octopus on Argo CD previews. Even without such a platform, you can schedule the same script from CI against a long-lived staging database.

Test the seed itself and wire it into CI

A seed script is an application, so test it like one. Three checks catch nearly every problem:

  1. Pipeline test. Run the seed against an empty database created from the current schema, then run it again. The second run must either skip cleanly (checksum match) or reload to identical contents. Compare row counts and a sample of pseudonyms across the two runs; any difference means a nondeterministic generator slipped in.
  2. Permission coverage assertion. After loading, query for every role key in the role fixture and assert it has at least one assigned user. A failed assertion points at a fixture that will never be exercised.
  3. Tenant isolation assertion. For each tenant, count rows visible through the application's tenant-scoped queries and compare with the fixture counts. A mismatch means a scoping bug, and finding it here is much cheaper than finding it during user acceptance testing.

Run all three in the CI pipeline for the pull request that changes a fixture, then gate the deploy. Keep the seed fast: truncate and reload only the tables the fixtures own, and never touch tables that migrations manage. If a schema migration adds a new required column to users, the seed will fail loudly in CI, which is precisely the early warning you want before a staging refresh fails an hour before a demo.

Staging should mirror production's configuration and resource shape while staying isolated and safe Marker.io staging guide. A known, reproducible dataset is part of that safety. Teams that treat staging data as infrastructure rather than a one-off copy spend their review time on features instead of debugging the environment; if you run several services or a mix of models behind them, an inference gateway with one API key (https://quantaintelligence.ai/inference) keeps the configuration in staging matched to production.

Worked example: seeding three tenants and checking drift

Suppose you have three tenants: one on the enterprise plan with 40 members, one on team with 15, and one trial tenant with 2. Your fixtures declare 57 users across 6 roles. The first run inserts 57 users, 57 memberships, 60 role assignments (three users hold two roles), and writes a ledger row with the checksum 9f3a... and counts {"users": 57, "memberships": 57, "role_assignments": 60}.

Two weeks later a developer adds a fixture user to the team tenant to reproduce a bug. The checksum changes, so the next CI run reloads and the ledger grows a second row. A week after that, someone manually promotes a user to owner in staging to demo an admin screen. The ledger still says 57 users and the table holds 57 users, so the count check passes, but the next reload wipes the manual change and the demo breaks. The lesson: manual edits to staging are always temporary, so demos should be built on fixtures, not on hand-edited rows. Add a demo fixture set alongside the base set if a scripted demo needs extra rows, and load it with --fixture-set demo.

The drift check itself is cheap: compare the newest ledger checksum with the one the script computes on startup, and compare ledger counts with live counts. Two queries, one alert.

FAQ

FAQ

How is a seed script different from a database migration?

A migration changes schema and ships with the application; a seed script populates rows and ships as fixture files. Migrations run identically everywhere and are ordered; seeds are recreatable content that you can truncate and reload. Keep them in separate tooling so a reseed never rewrites schema history.

Should staging keep any real production data at all?

No, except in narrow cases where the schema genuinely cannot be represented synthetically. Copying production data into staging creates a compliance exposure, and the hand-maintained anonymization script behind most copies goes stale as tables are added staging without production data. Generated data with deterministic pseudonyms avoids the question.

What makes a good column for the checksum?

Hash the fixture file contents, not file modification times, because timestamps vary across branches and checkouts. Sort the file paths before hashing so a reordered directory listing does not produce a spurious change. Include the fixture set name if you support more than one set.

How do I keep fake data realistic enough for testing?

Use word lists for names, reserved-domain emails, and valid formats for phone numbers and identifiers, but keep generation deterministic. Preserve the shape while removing identity, which is the same principle anonymization guides describe: email masking keeps the domain while obfuscating the local part anonymization in dev workflows. Add one deliberately skewed tenant for realistic volume.

Can I run the seed on every environment, including local?

Yes, and you should. The same fixtures and the same script in local, CI and staging mean a bug reproduced in one environment reproduces in the others, which is the point of parity across backing services the Twelve-Factor App. Gate the destructive path behind an explicit --force flag so a laptop run cannot wipe a shared database by accident.

Sources

Sources

  1. X. Dev/prod parity - The Twelve-Factor App
  2. Staging Without Production Data: Generate, Don't Copy
  3. Decide on Seeding Strategy for Staging Environments
  4. Anonymization: The Missing Link in Dev Workflows
  5. What is a staging environment? (and how to set one up) | Blog - Northflank
  6. Auto-Create Preview Environments for Every PR - Bunnyshell
  7. Create Temporary Argo CD Preview Environments Based On Pull ...
  8. What is a Staging Environment? Guide & Best Practices - Marker.io
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles