← Back to the blog

Testing on production data, with the personal data masked

Tutorial · Updated · 15 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 a middle way: on every pull request, a branch forked from production, dynamic masking applied to it, the test suite running against real volumes while reading only fake values, and deletion when the PR closes. To be clear from the start: the real data stays in the branch, only reading it is masked. This is not anonymisation in the GDPR sense — at best pseudonymisation, which reduces the exposure of personal data in testing without making the branch anything other than processing of that data.

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. Masking reads goes that way, but the branch still holds the real addresses: it is still processing of personal data, to be governed as such.
What we want
Real volumes and distributions, fake identities on read, 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 bounded lifetime, with deletion when the pull request closes and a TTL (Time To Live) as a safety net.

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 digestWhen 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. The name is unique within the project — creating it a second time returns 409. Keep its identifier — everything else hangs off it.

bashterminal
# The PR branch, forked from main (for --parent, the project ID means main), deleted automatically after 72 h
$ export PROJECT_ID=3f8a2c1e9b7d4f60a5e2c8d1b4f7a903
$ export BRANCH_ID=$(lampion branches create "$PROJECT_ID" pr-482 \
    --parent "$PROJECT_ID" --ttl 72 --json | jq -r .id)

# Its identifier: this is what every following command expects
$ echo "$BRANCH_ID"
9c4a71f2e08d4b3a8f61d2c7e5b09a14
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 masked, 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 — and even afterwards, it remains a copy of it.

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 excluded from masking email last_name [email protected] Dupont [email protected] Martin lampion_analyst · app_user · masked roles 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 "$PROJECT_ID" "$BRANCH_ID"
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 "$PROJECT_ID" "$BRANCH_ID" \
      --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. The API refuses the operation on the primary branch (400).
Analyst role
lampion_analyst is created (or its password rotated), read-only on the public schema, with its own connection string.
Masked roles
Every non-system role present at enable time gets SECURITY LABEL … IS 'MASKED', lampion_analyst included; a role created later on the branch through the API (console, lampion roles create) is masked too. Only cloud_admin is excluded: it sees the real data. A role created any other way, in SQL as cloud_admin, falls outside this guarantee.
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 (abridged output)
$ lampion anon status "$PROJECT_ID" "$BRANCH_ID"
Enabled: True
id            schema_name  table_name  column_name  masking_function
a3f9c210b74e  public       users       email        anon.fake_email()

This double check is not belt and braces. A rule that fails to apply on the compute is now rejected (502) and not recorded — for a long time it was recorded anyway, with a 201 and a listed rule that masked nothing. But the status remains a declaration: it sees neither a personal column missing from masking.json nor a suite wired to the wrong role. The data is the only reliable witness, and it is the assertion to put in CI.

sqltests/assert_masked.sql
-- Run as the role your tests use (here lampion_analyst), never as cloud_admin.
-- The first error stops the script, and psql exits with a failure.
\set ON_ERROR_STOP on

DO $$
DECLARE
  total  bigint;
  leaked bigint;
BEGIN
  -- 0. An empty table proves nothing.
  SELECT count(*) INTO total FROM users;
  IF total = 0 THEN
    RAISE EXCEPTION 'table users is empty: nothing to verify';
  END IF;

  -- 1. No address may belong to a known customer domain.
  SELECT count(*) INTO leaked
    FROM users
   WHERE lower(split_part(email, '@', 2)) IN ('acme.fr', 'beta-sa.fr');
  IF leaked > 0 THEN
    RAISE EXCEPTION 'masking inactive: % customer-domain address(es) readable in the clear', leaked;
  END IF;

  -- 2. Canary row, created on purpose in production: its real value must never come back.
  IF EXISTS (SELECT 1 FROM customers WHERE company = 'Temoin CI SAS') THEN
    RAISE EXCEPTION 'masking inactive: the canary row comes back in the clear';
  END IF;
END $$;

The assertion to automate

A known domain
After masking, no address should belong to your customer domains. A query counting them is enough, and fails the script on the first hit.
A canary value
Create a row in production 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 reads 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 if it existed at enable time or was created later through the API. Run the assertion as that role.
Never cloud_admin
It is excluded from masking by design. 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 "$PROJECT_ID" --json \
    | jq -r --arg id "$BRANCH_ID" '.[] | select(.branch_id == $id) | .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 previous steps, assembled: one branch per pull request, created on the first run and reused on the next ones, masking, verification, tests, then deletion on close.

01 PR opened workflow triggered 02 Fork current LSN · 72 h TTL 03 Masking enable + rules 04 Verification the data, not the status 05 Tests masked role 06 Deletion on close There is no per-branch fee: only the compute you burn and the storage you keep are billed.
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, reopened, synchronize, closed]

# One run at a time per pull request: two pushes in quick succession
# do not create two branches.
concurrency:
  group: ${{ github.workflow }}-${{ github.event.number }}

defaults:
  run:
    shell: bash   # bash -eo pipefail: any failing command stops the step

env:
  # Key generated by an admin account: anon enable requires it.
  LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
  PROJECT_ID: 3f8a2c1e9b7d4f60a5e2c8d1b4f7a903
  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>=0.2.1"

      # Created on the first run, found by name on later pushes.
      - name: Fork production
        run: |
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          if [ -z "$BRANCH_ID" ]; then
            # --parent = the project ID, i.e. main; --ttl = safety net if cleanup never runs.
            BRANCH_ID=$(lampion branches create "$PROJECT_ID" "$BRANCH" \
              --parent "$PROJECT_ID" --ttl 72 --json | jq -r .id)
            echo "NEW_BRANCH=true" >> "$GITHUB_ENV"
          fi
          test -n "$BRANCH_ID" && test "$BRANCH_ID" != "null"
          echo "BRANCH_ID=$BRANCH_ID" >> "$GITHUB_ENV"

      # main is not masked: the branch inherits no rules, so we apply them.
      - name: Mask
        if: env.NEW_BRANCH == 'true'
        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 id "$BRANCH_ID" \
                '.[] | select(.branch_id == $id) | .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
        id: tests
        run: npm test

      # Failure before the tests (masking missing or unproven): the branch is
      # deleted, and the next push starts again from a fresh fork.
      - name: Delete a badly masked branch
        if: failure() && env.BRANCH_ID != '' && steps.tests.outcome == 'skipped'
        run: lampion branches delete "$PROJECT_ID" "$BRANCH_ID"

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - run: pip install "lampion-cli>=0.2.1"
      - name: Delete the pull request branch
        run: |
          BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
            | jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
          if [ -n "$BRANCH_ID" ]; then
            lampion branches delete "$PROJECT_ID" "$BRANCH_ID"
          fi

Verification runs before the tests: if masking is not proven, the suite does not run, and the branch is deleted so the next push starts from a fresh fork. Masking is applied once, when the branch is created. A branch created from a masked parent inherits its status and rules; but here the parent is main, which is not masked and cannot be — so the workflow applies the rules itself, which is tedious by hand and free in a workflow.

Step 07

Compare performance along the way

Since the branch carries real volumes, a comparator — through the API or the console, with no CLI command — replays main’s most expensive queries against it.

bashbash
# Compare the expensive queries between main’s endpoint and the PR’s
$ curl -X POST "https://api.lampion.cloud/v1/projects/$PROJECT_ID/replay" \
    -H "Authorization: Bearer $LAMPION_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"source_endpoint_id": "ep-3f8a2c1e9b7d",
         "target_endpoint_id": "ep-9c4a71f2e08d",
         "limit": 20}'
# Abridged response: per-query details are in "queries"
{"summary": {"total": 20, "regressions": 1, "improvements": 3, "stable": 12}}

It runs once on the target each of the source’s queries with the highest cumulative time (pg_stat_statements): above ×2, a regression; below ×0.5, an improvement. Parameterised queries are skipped, with no concurrency and no real ordering: it is a comparison bench, not a traffic replay.

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
The workflow creates the branch with --ttl 72 (anywhere from 1 to 720 hours); once that time has elapsed, the platform’s periodic cleanup task deletes it. It is the safety net for when the cleanup job fails or the pull request is abandoned — and a pull request left open longer simply gets a fresh branch on its next push.
Explicit deletion
The cleanup job finds the branch by name and deletes it by ID 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.
Not on main
The API refuses to enable masking or add a rule on the primary branch (400): masking belongs on a test branch, never on production itself.
The cost
There is no per-branch fee; an idle compute suspends after five minutes. A forgotten pull request branch uses no compute once suspended, and its TTL eventually deletes it.
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 is created with --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. 09Test branches are listed in your record of processing activities: masked on read, their data is still personal data.
  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.