Two places where a team's mental model is wrong in a way that feels safe. One is a legal misreading, the other a shortcut — and they compound, because a hashed production copy in dev is two mistakes that each hide the other.
Hashing personal data is pseudonymisation under GDPR Art. 4(5). For the controller who holds the mapping — which is you — the result is still personal data: still in scope, still subject to erasure requests, retention limits and the record of processing activities. This is the most commonly wrong claim in DACH data projects, and it is normally stated with complete confidence in the first workshop.
The canonical model makes the point without needing an extra example. customer_sk = sha2(concat_ws('||', customer_id), 256), and gold.fct_order_line carries customer_sk with no contact columns — so it is tempting to call the fact table PII-free. It is not. gold.dim_customer holds the mapping back to customer_id and email. That mapping is the 'additional information' in Art. 4(5), and holding it is exactly what keeps the pseudonym personal.
Why
The consequence is operational, not academic. If a hashed dataset is personal data then the dev copy of it is a transfer, the retention clock runs on it, erasure requests reach it, and it has to appear in the processing register. A team that believes hashing settled the question skips all four, and the gap gets found by an auditor rather than by them.
The useful framing in the room is the three tests from Article 29 Working Party Opinion 05/2014 — the EDPB's predecessor, and still what a DACH regulator reasons from: can you single out an individual, can you link records across datasets, can you infer an attribute. A deterministic hash fails the second one by design — preserving linkability is the whole reason you built it.
One qualification, best supplied by you rather than by the client's lawyer. Personal data is a relative concept — relative to the controller in question and to the means reasonably likely to be used to identify someone (Breyer, C‑582/14; EDPS v SRB, T‑557/20 and the 2025 CJEU appeal). The same pseudonymised extract can be personal data in your hands and non-personal in the hands of a recipient with no access to the mapping and no realistic route to one. That does not soften the argument for this warehouse — silver.customer and gold.dim_customer hold the mapping, you hold both catalogs, and for you the hash is unambiguously personal data. It does mean the sentence in the concept names the controller: 'hashed data is always personal data, for everyone' is the one claim here a data-protection lawyer will take apart.
How
sqlWhy a hash over a known population is not a secret
-- The 'additional information' need not even be a lookup table. Given the
-- customer list you already have, the hash inverts by enumeration: forty
-- thousand SHA-256 evaluations, well under a second.
SELECT sha2(concat_ws('||', customer_id), 256) AS customer_sk,
customer_id,
email
FROM dwh_prod.silver.customer
WHERE _tech_is_current;
What does reach anonymisation is losing the ability to single a person out. gold.agg_revenue_month at month × region × product group is anonymous while every cell aggregates enough customers — and stops being anonymous the moment a region contains one of them. That is what a small-cell suppression threshold is for, and it is a number, not a principle. A number alone is still not enough: the suppression rule has to hold across every slice anyone can request, not just the published one, because two permitted queries that differ by one month or one product group subtract to a single customer's revenue.
Doing it
Write 'pseudonymisiert', never 'anonymisiert', in German documentsThe two words carry the legal distinction directly, and using them loosely is how the misunderstanding spreads.
Use the hash for what it is good at — a stable, non-guessable join keyProtection comes from masks and from not carrying the column at all.
Where genuinely anonymous output is needed, aggregate and suppress small cellsThe threshold is written down as a figure.
Do not sell salt-and-forget as anonymisation unless the salt is provably destroyedA salt in a secret scope is a salt you still hold.
Embedding it
Run the enumeration above live against the real customer listNinety seconds, and 'but it's hashed' never comes back.
Make the team write the sentence for the concept themselves, in GermanGetting the wording right is where the understanding actually lands.
When someone proposes hashing to solve an access problem, ask who can read the mappingThe answer is 'we can', and the question answers itself.
Defaults
Hash to join, mask to protect, aggregate to anonymiseThree jobs, three mechanisms, never substituted for each other.
Pseudonymised datasets inherit the retention, erasure and access rules of their source
The suppression threshold is a documented numberIt is enforced on every slice that can be requested rather than on the one report you looked at.
'Anonymised' appears in a document only where no individual can be singled outThe sentence names the controller it is true for.
Gotchas
'It's hashed, so it's anonymous'For the controller holding the mapping the dataset stays in scope, so the retention clock, the erasure duty and the processing register all still apply — and none of them were built, because the team believed they were exempt.
Hashing a low-cardinality columnsha2 over postal_code, country or customer_segment brute-forces instantly: the hash is unreadable to your own analysts and perfectly readable to anyone with a motive.
Hashing to enable a join between two datasets and then calling it a privacy controlLinkability is precisely what was built, and it is the second of the three tests above.
Stripping contact columns from an extract but keeping customer_skThe recipient cannot read an email and can still single out one person and follow them across every row you sent.
Suppressing small cells only in the published aggregateDifferencing does the rest: this month minus last month, or all product groups minus one, reconstructs the cell you hid. The threshold has to bind on the query surface the consumer actually has, and a BI tool with free slicing is that surface.
The rule is one sentence — no production data in dwh_dev, no exceptions, no 'just this once'. Breaking it takes half a line of SQL that looks entirely reasonable in a pull request:
Why
Two failures, and the second is the one that lasts. The first is obvious: personal data in a catalog with looser grants and a differently bound workspace. The second is that a copy is a snapshot — it drifts from production's schema, and the tests passing against it stop proving anything. The team ends up with a compliance exposure and a test suite that lies, and only notices the second one.
How
sqlThe statement everyone reaches for — and who it actually works for
CREATE TABLE dwh_dev.gold.dim_customer
DEEP CLONE dwh_prod.gold.dim_customer;
Run that as an ordinary engineer and it fails. Deep and shallow clones are not supported on tables carrying an ABAC policy, and gold.dim_customer is covered by the schema-level mask policy on dwh_prod.gold. The documented way to make the statement work is to put a service principal or group in that policy's EXCEPT clause.
Read that twice, because it inverts the usual reassurance. The only principals who can make the forbidden copy are the ones you exempted — and for them the copy lands completely unprotected, because the policy is attached to dwh_prod.gold, not to the data. The clone is a new securable in a schema no policy covers, so nothing in dwh_dev masks anything. The platform blocks the people who were never the risk and waves through the pipeline service principal that had to be exempted to do its job — and that copy is made in good faith, by an identity entitled to read the source.
Test data comes from factories instead. The fixtures schema in dwh_test is seeded by the same row factories the PySpark tests use, and synthetic contact data uses reserved ranges — addresses at @example.com, which RFC 2606 reserves so a stray test mail can never reach a real inbox, and numbers from the block the national regulator reserves for drama and testing.
Doing it
Remove the grant that makes it possible: dev principals get no SELECT on dwh_prodWorkspace-catalog binding sits behind it. Grants and binding are the control that also holds for the exempted identities — the ABAC clone restriction, by construction, does not.
Review every principal in the policy's EXCEPT clause as a clone risk, not only as a mask riskThe exemption lifts both in one word, and the pull request that adds it reads like a job fix.
Build the fixture factories in week oneThe rule only holds while the compliant path is faster than the copy.
When production shape is genuinely needed, copy the distribution — row counts, cardinalities, null ratesGenerate rows to match. A profile is not personal data.
If a masked subset must exist, produce it on the production side and move only the resultMasking after the copy has landed is masking after the incident.
Embedding it
Ask where the current dev data came fromSomebody always knows, and the answer is usually a clone from last year.
Run a classification scan on dwh_dev in front of themThe findings make the argument so you do not have to.
Have them write the dim_customer fixture factory, not youA team that owns its fixtures stops wanting the copy.
Defaults
Fixtures generated by factories; no path from prod data into dev, enforced by grants rather than by policy prose
Distribution profiles, not rows, when volume realism matters
Any masked extract is produced on the prod side of the boundary
The test-data rule is a named chapter in the anonymisation concept with the DPO as a reviewer
Gotchas
Concluding the platform prevents the cloneIt refuses the statement for everyone subject to the policy, which is most of the team, so the control looks absolute — and it does not refuse the identities in EXCEPT. Their copy lands in dwh_dev with no policy over it, every developer reads raw email addresses, and the audit trail records one legitimate read by an entitled principal.
Adding a service principal to EXCEPT so a clone-based backup or restore job runsThat is the same edit as removing the mask from everything the job touches, and it also removes the clone restriction — the copy it makes lives outside the policy from the first byte.
'Anonymised' dev data that keeps customer_id or customer_skThe names are gone; every individual is still singled out and followable across every table.
The one-off debug copyIt never gets dropped, because the ticket that justified it closed and nobody owns the cleanup. You find it a year later in a classification scan.
Dropping the copy and calling it doneThe files persist until VACUUM passes the retention threshold, which turns 'we deleted it' into 'we deleted it, in about a week'.