← Back to the blog

Testing on production data, without the personal data

Tutorial · 14 min read · Lampion team

A test dataset that looks like production does not exist. Fixtures hold two hundred evenly spread rows; production holds one customer with four million rows, three duplicate addresses and a column nobody has filled in since 2023.

This tutorial builds the third way: on every pull request, a branch forked from production, dynamic masking applied to it, the test suite running against real volumes with fake identities, and deletion when the PR closes. An hour to set up, then nothing left to do.

Context

The test-data dilemma

Between fixtures that resemble nothing and a copy of production on a developer’s machine, most teams pick the first and discover the second during an incident.

The bugs that cost money do not show up on two hundred evenly spread rows. They show up on a forty-million-row table, on a distribution where one customer accounts for 60 % of the volume, on null values nobody planned for. A synthetic dataset never produces them — it produces exactly what its author had in mind, which is precisely the problem.

Fixtures
Invented volumes, cardinality and distributions. Query plans are wrong, indexes pointless and regressions invisible.
The production dump
A copy of personal data in a test environment, often on a laptop, sometimes in a bucket: processing in its own right, rarely on the record.
What the law says
Minimisation (GDPR article 5) means processing only what the purpose requires. Testing a query does not require reading a real email address.
What we want
Real volumes and distributions, fake identities, and nothing that outlives the pull request.

The third path rests on three pieces that already exist: a Lampion branch, which is a copy-on-write fork of production at the current LSN (Log Sequence Number); the postgresql_anonymizer extension, which masks on read without rewriting a single row; and a lifetime that deletes the branch when the pull request closes.

Step 01

Declare the sensitive columns

Which columns are personal, and what to replace them with: that is a team decision, not an infrastructure setting. So it belongs in the repository, reviewed in a pull request like everything else.

jsonmasking.json
[
  { "table": "users",     "column": "email",      "function": "anon.fake_email()" },
  { "table": "users",     "column": "last_name",  "function": "anon.fake_last_name()" },
  { "table": "users",     "column": "phone",      "function": "anon.random_phone($$0X XX XX XX XX$$)" },
  { "table": "customers", "column": "company",    "function": "anon.fake_company()" },
  { "table": "customers", "column": "iban",       "function": "anon.fake_iban()" },
  { "table": "orders",    "column": "notes",      "function": "$$REDACTED$$" }
]

Versioning this file has a useful side effect: adding a personal column without adding its rule becomes visible in code review. It is also the document you will show when someone asks which data leaves for testing.

The accepted masking functions

The API accepts only this list — any other value is rejected with a 400. It is an allowlist, and it is what keeps a masking rule from becoming an SQL injection vector.

FunctionWhat it returnsGood for
anon.fake_email()A plausible, fake addressEmail columns — the format stays valid
anon.fake_first_name()A first name from the dictionaryFirst names, display columns
anon.fake_last_name()A surname from the dictionaryLast names
anon.fake_city()A cityPostal addresses
anon.fake_company()A company nameB2B customer names
anon.fake_iban()A well-formed IBANBank details
anon.fake_siret()A well-formed SIRETCompany identifiers
anon.partial({COL},2,$$***$$,2)First 2 and last 2 characters, the rest maskedPhone numbers, references — keeps the length
anon.hash({COL})A stable SHA-256When equality between rows must survive
anon.random_string(10)Ten random charactersFree-form fields with no imposed format
anon.random_zip()A postal codePostal codes
anon.random_date()A random dateDates of birth
anon.random_phone($$0X XX XX XX XX$$)A number in French formatPhone numbers when the format matters
$$REDACTED$$A constantFree-text comment fields
NULLNothingColumns the tests do not need
anon.hash() deserves a note: it is the only function that preserves equality. Two rows sharing an address will still share it after masking — essential if your tests are about duplicates or joins on email, and to be avoided on low-cardinality columns, where the digest can be re-identified by simple counting.
Step 02

Create the pull request branch

A copy-on-write fork at main’s current LSN: real data, in seconds, with no physical copy. Keep its identifier — everything else hangs off it.

bashterminal
# The PR branch, forked from main
$ lampion branches create prj_a1b2c3d4 pr-482 --parent prj_a1b2c3d4
✓ pr-482 · copy-on-write fork at current LSN · seeds replayed (3)

# Its identifier: this is what the masking commands expect
$ lampion branches list prj_a1b2c3d4 --json | jq -r '.[] | select(.name == "pr-482") | .id'
br-9c4a71f2e08d
What the branch actually holds

At this point the branch holds production data, in the clear. The copy-on-write fork shares the parent’s storage: nothing has been anonymised, nothing has even been copied. The masking in the next step acts on reads, not on content. Until it is enabled and verified, treat this branch exactly like production.

Step 03

Enable masking and apply the rules

One command installs the extension and switches the compute into dynamic masking; a loop applies the rules from the repository. No data is rewritten.

public.users — one table, one storage cloud_admin unmasked — the only one email last_name [email protected] Dupont [email protected] Martin lampion_analyst · app_user · everyone else SECURITY LABEL … IS 'MASKED' email last_name [email protected] Lefevre [email protected] Girard
Fig. 1 — Dynamic masking does not touch the rows: it rewrites the read. The same query, on the same table, returns different values depending on which role asks.
bashterminal
# Installs the anon extension, creates the analyst role, starts dynamic masking
$ lampion anon enable prj_a1b2c3d4 br-9c4a71f2e08d
✓ Anonymization enabled. lampion_analyst role created.

# Applies the repository rules, one per column
$ jq -c '.[]' masking.json | while read -r r; do
    lampion anon add-rule prj_a1b2c3d4 br-9c4a71f2e08d \
      --table    "$(jq -r .table    <<<"$r")" \
      --column   "$(jq -r .column   <<<"$r")" \
      --function "$(jq -r .function <<<"$r")"
  done
✓ Rule added: public.users.email -> anon.fake_email()

What enable does, precisely

CREATE EXTENSION
anon is installed on the branch compute, then anon.init() loads the dictionaries of fake values.
Analyst role
lampion_analyst is created (or its password rotated), read-only on the public schema, with its own connection string.
All masked but one
Every non-system role gets SECURITY LABEL … IS 'MASKED'. Only cloud_admin is excluded: it is the one role that still sees real data.
Dynamic masking
anon.start_dynamic_masking() puts it all into service. Rows are never modified — substitution happens on read.
The API key’s role matters

Enabling or disabling masking and managing rules require the admin role; reading the status and the rule list only needs project access. An API key inherits the role of whoever created it in the organisation — so a key made by a developer account will return 403 on anon enable. Have an admin or owner generate the CI key, and store it as a repository secret.

Step 04

Prove the masking holds

This is the step people skip, and the only one that proves anything. The status shown by the console says what was requested; only a read says what is in force.

bashterminal
# What the console believes
$ lampion anon status prj_a1b2c3d4 br-9c4a71f2e08d
Enabled: true
id            schema  table  column      masking_function
a3f9c210b74e  public  users  email       anon.fake_email()

This double check is not belt and braces. When applying a rule fails on the compute, it is still recorded on the console side and the API answers 201: the status can therefore show a rule that masks nothing. The data is the only reliable witness, and it is also the assertion to put in CI.

sqltests/assert_masked.sql
-- The same query, with both connection strings.
DO $$
DECLARE leaked int;
BEGIN
  SELECT count(*) INTO leaked
    FROM users
   WHERE email LIKE '%@acme.fr'
      OR email LIKE '%@beta-sa.fr';

  IF leaked <> 0 THEN
    RAISE EXCEPTION 'masquage inactif : % adresse(s) reelle(s) lisibles', leaked;
  END IF;
END $$;

The assertion to automate

A known domain
After masking, no address should belong to your customer domains. A query counting addresses outside the fake dictionary is enough.
A canary value
Take one row whose real value you know and check it does not come back. That is the test that fails the day a rule disappears.
Coverage
Compare the column list in masking.json with what anon status returns: one missing rule, and CI stops.
The right role
Check with the role your tests will use, not with cloud_admin — which will always see the real values.
Step 05

Run the test suite

Nothing changes in the test code: only the connection string does. Because masking is carried by the role, the suite sees fake identities without knowing it.

Choosing the suite’s role

lampion_analyst
Read-only. Ideal for a suite that only reads, query tests, or a review of execution plans.
The application role
If the suite writes, connect with the application role: it is masked too, since every role is except cloud_admin.
Never cloud_admin
It is the one unmasked role. Using it for tests silently cancels everything above.
What stays true
Volumes, cardinality, distributions, indexes, query plans: masking only changes the values returned, not the shape of the data.
bashterminal
# The analyst connection string, published by the endpoint once masking is on
$ export DATABASE_URL=$(lampion endpoints list prj_a1b2c3d4 --json \
    | jq -r '.[] | select(.name == "pr-482") | .analyst_connection_string')

# Then the suite, unchanged
$ npm test
✓ 214 passed · 0 failed

A pleasant consequence: tests run against the same amount of data as production, so slow queries are slow in tests too. That is the main point of the exercise, ahead of compliance.

Step 06

The complete workflow

The six previous commands, assembled: pull request opened, masking, verification, tests, then deletion on close.

01 PR opened workflow triggered 02 Fork branch at current LSN 03 Masking enable + rules 04 Verification the data, not the status 05 Tests masked role 06 Deletion on close Branching is never billed: only the compute you burn and the storage you keep.
Fig. 2 — The full cycle. The first four steps take seconds; the last one is the one you must never make optional.
yaml.github/workflows/masked-tests.yml
# A masked production database per pull request, destroyed on close
name: masked-tests
on:
  pull_request:
    types: [opened, synchronize, closed]

env:
  # Key generated by an admin account: anon enable requires it.
  LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
  PROJECT_ID: prj_a1b2c3d4
  BRANCH: pr-${{ github.event.number }}

jobs:
  test:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install lampion-cli

      - name: Fork production
        run: |
          lampion branches create "$PROJECT_ID" "$BRANCH" --parent "$PROJECT_ID"
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          echo "BRANCH_ID=$BRANCH_ID" >> "$GITHUB_ENV"

      - name: Mask
        run: |
          lampion anon enable "$PROJECT_ID" "$BRANCH_ID"
          jq -c '.[]' masking.json | while read -r rule; do
            lampion anon add-rule "$PROJECT_ID" "$BRANCH_ID" \
              --table    "$(jq -r .table    <<<"$rule")" \
              --column   "$(jq -r .column   <<<"$rule")" \
              --function "$(jq -r .function <<<"$rule")"
          done

      - name: Fetch the masked connection string
        run: |
          URL=$(lampion endpoints list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" \
                '.[] | select(.name == $b) | .analyst_connection_string')
          test -n "$URL" && test "$URL" != "null"
          echo "::add-mask::$URL"
          echo "DATABASE_URL=$URL" >> "$GITHUB_ENV"

      # Masking is proven on the data, never on the status.
      - name: Verify masking
        run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f tests/assert_masked.sql

      - name: Tests
        run: npm test

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - run: pip install lampion-cli
      - run: |
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          lampion branches delete "$PROJECT_ID" "$BRANCH_ID"

The verification job comes before the tests on purpose: if masking is not in place, the suite must not run at all. And since rules are not inherited when a branch is created, they are reapplied every time — tedious by hand, free in a workflow.

Step 07

Check performance along the way

Since the branch carries real volumes, you may as well compare the heavy queries between production and the pull request.

bashbash
# Compare the expensive queries between two endpoints
$ curl -X POST "$LAMPION_API/v1/projects/prj_a1b2c3d4/replay" \
    -H "Authorization: Bearer $LAMPION_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"source_endpoint_id": "ep-4f9c21ab8d3e",
         "target_endpoint_id": "ep-7d31c8f0a94b",
         "limit": 20}'
{"summary": {"total": 20, "regressions": 1, "improvements": 3, "stable": 12}}

Let us name it properly: this is not a replay of production traffic, it is a benchmark of heavy non-parameterised queries. Useful for catching an index dropped by accident or a join gone quadratic; useless for predicting behaviour under load. There is no CLI command for this call — it is the API or the console.

What this comparator really does

Capture
The source’s most expensive queries, read from pg_stat_statements and ranked by cumulative total time.
Replay
Each one is executed once on the target, and the measured time is compared with the source’s mean time.
Verdict
A ratio above 2 is flagged as a regression, below 0.5 as an improvement, everything else is stable.
Blind spots
Parameterised queries are skipped, each query runs once only, with no concurrency and no respect for real ordering.
Step 08

Destroy the branch, and why that is not optional

Masking rewrites the read, not the storage. A masked branch is still a logical copy of production: deleting it is part of the mechanism, not the housekeeping.

Storage shared with main — the real values, untouched anon · masking applied on read lampion_analyst, app_user, CI → fake values cloud_admin → real values, mask bypassed A masked branch is still a copy of production: short TTL, no export, no owner connection string in the logs.
Fig. 3 — What dynamic masking does not do. The real data is still there, in storage shared with production. What is controlled is who can read it in the clear — not whether it is present.
TTL
Give the branch a lifetime between one hour and thirty days. It is the safety net for when the cleanup job fails or the pull request is abandoned.
Explicit deletion
The cleanup job removes the branch when the pull request closes, without waiting for expiry.
No export
lampion dump from a masked branch runs as the owner role: the resulting file holds the real values. It is not a way to build a shareable test dataset.
Secrets
Mask the connection string in workflow logs and never expose cloud_admin’s to a test job.
Never on main
The intended use is the test branch. Nothing in the API technically prevents enabling masking on the primary branch — so it is a team rule to keep, not a guardrail to lean on.
The cost
Branching is never billed; an idle compute suspends after five minutes. A forgotten pull request branch costs the storage of its writes, nothing more.
Checklist

Before wiring this into every pull request

Ten checks. The first four prevent a leak, the next six prevent false confidence.

  1. 01masking.json is versioned in the repository and reviewed in pull requests, like migrations.
  2. 02Every personal column in the schema has its rule — the list is compared automatically with what anon status returns.
  3. 03The test suite connects with a masked role, never with cloud_admin.
  4. 04The data-level verification runs before the tests, and CI stops if it fails.
  5. 05The workflow’s API key was generated by an admin or owner account, otherwise anon enable returns 403.
  6. 06The connection string is masked in workflow logs.
  7. 07The branch has a TTL, and the cleanup job deletes it when the pull request closes.
  8. 08No export is produced from a masked branch: the dump returns real values.
  9. 09Masking is never enabled on the primary branch, since no technical guardrail prevents it.
  10. 10anon.hash() is only used on high-cardinality columns, otherwise the digest is re-identifiable by counting.

Tests that lie
a little less.

Free: 3 projects, 3 branches, 0.25 CU, 512 MB of storage — no credit card. Data hosted in France.