Writing · a measured result

You don't need to read the data to find the personal data.

Check it Every number below comes from a held-out benchmark that ships in the repo — pnpm bench:classifier re-runs it, prints the misses, and fails the build if a floor drops. The schemas are named, so you can check the claims rather than trust them.

Somebody asks for "a list of everywhere we store personal data."

If you are the engineer that request lands on, you know the week that follows: a spreadsheet, a tour of every database you have credentials for, and a document that is out of date by the next migration.

Here is the premise I built on: you rarely need the data for this job. You need to know where personal data lives, and most of that is already written down — in the schema. A column called iban in a table called payroll tells you what you need without reading a row. So the tool reads structure only — table and column names and types, never the values — and the scan talks to nothing but the sources you configure.

That constraint is not a privacy pose. A mapping tool that reads production data becomes the thing your security review is about — one more system with read access to everything. A scan that touches only information_schema is one you can run without a meeting, and running it without a meeting is the only way the map stays current.

The interesting question is how far names alone actually get you. So I measured it.

The naive version fails loudly

The obvious implementation is substring matching against a word list, and it is why this approach gets dismissed. trace_id contains "race". A substring matcher flags it as ethnicity — and that is not an ordinary mistake. Ethnicity is special-category data under GDPR Article 9, the kind of flag a compliance team has to stop and clear. A tool that cries "ethnicity" at a tracing header costs them a week instead of saving one.

Substring vs whole-token — three trap columns
Substring trace_id ✗ flagged as ethnicity "race" found inside "trace"
Whole-token traceid ✓ clean no rule matches either token
Whole-token raceconditioncount ✓ clean the rule is =race — a column named exactly race
Whole-token geneticalgorithmgeneration ✓ clean the genetic rules are sequences: genetic_data, genetic_test
Tokenization is the whole trick. A column name is split on underscores, dashes, digits and camelCase; rule terms match whole tokens or consecutive sequences — never fragments inside a word.

So matching is whole-token, never substring. A column name is tokenized — split on underscores, dashes, digits and camelCase — and a rule term matches whole tokens or consecutive token sequences.

There are four term forms in the rule syntax, and the loosest one is deliberately caged. name matches a whole token. date_of_birth matches a consecutive sequence. =race matches an exact full name. And religio* matches a token stem — the form closest to substring matching, allowed on the deny list only: the special-category and criminal-offence packs, where missing religionszugehoerigkeit is worse than over-flagging it. Ordinary personal data never gets a stem rule. Precision is the point of the whole exercise, and the one place recall is bought at its expense is the place a miss would be an Article 9 miss.

Rules, not a model

The classifier is a rule engine, not a model, and that is a choice with an argument behind it.

Every flag says how the column was recognised — the matched term, by field name — so a reviewer can audit the decision in one look. When it is wrong, the fix is a one-line rule change with a test, not a retraining run. It runs locally in milliseconds with no model call, which the metadata-only architecture requires anyway. And a rule list can be read, end to end, by the person accountable for the result — which matters when the output feeds a record someone signs.

The cost of rules is recall on creative names. That cost has to be measured, not asserted.

The benchmark — with the misses printed

The benchmark is held out from development: schemas the rules were not written against, labelled by hand. The English set is drawn from real open-source products — OpenMRS (healthcare), Odoo and ERPNext (ERP and HR), Spree (e-commerce) — 349 labelled columns of real-world naming. The German, French and Dutch sets are authored, because the approach collapses if it only works in English and I wanted that failure on paper if it existed.

Recall on ordinary personal data, by benchmarked language
100 75 50 25 0 floor 0.85 92.5% 87.2% 95.0% 94.1% German English French Dutch
The gold line is the CI floor: 0.85 recall on ordinary personal data per benchmarked language. A rule change that drops any language below it — or puts one false special-category flag on the trap set — fails the build.
LanguageLabelled columnsOrdinary personal dataCaughtRecallPrecision
German55403792.5%100%
English34914112387.2%94.4%
French30201995.0%100%
Dutch35171694.1%100%

And the trap set: 34 infrastructure and telemetry columns with no personal data by construction, where the required score is zero false special-category flags. The current run: zero.

The trap set — infrastructure columns that must come back clean
34
columns, zero personal data by construction
0
false Article 9 special-category flags — the CI floor
0
false Article 10 criminal-offence flags
genetic_algorithm_generationrace_condition_counttrace_idconviction_ratelatency_p99gzip_ratiocardinality_estimateunion_query_count
Eight of the 34, chosen because a substring matcher fails them loudly. The full set ships with the benchmark.

Two qualifiers, and both are load-bearing:

"Ordinary" is doing work in that sentence. The floor covers ordinary personal data — names, addresses, contact details, dates of birth, financial columns. Linkage identifiers — the customer_id-shaped columns — are excluded from the floor and score far worse, because an id looks the same whether it points at a person or a pallet. Deciding which takes context a name does not carry.

Eleven language packs ship; four are benchmarked. The rule set is 596 terms across Danish, German, English, Spanish, French, Italian, Dutch, Polish, Portuguese, Romanian and Swedish. The benchmark covers four of them. Those are two different claims, and merging them into "multilingual, measured" would be flattering: a shipped pack is not a measured pack.

What it misses

The benchmark prints every miss instead of summarising them, and the misses are the honest half of the result.

Detection by kind of personal data — all languages, one run
credential100% · 20/20 dob100% · 7/7 email100% · 12/12 gender100% · 2/2 geo100% · 4/4 ip100% · 3/3 phone100% · 12/12 name92.9% · 26/28 other91.7% · 33/36 address91.4% · 32/35 financial87.0% · 20/23 employment66.7% · 18/27weakest national_id66.7% · 6/9weakest
The two gold rows are the honest half. Employment columns and national identifiers are where names alone carry the least — the counts are the benchmark's own, printed per kind on every run.

The weak categories are employment columns and national identifiers, both at 66.7%. The pattern across the failures is consistent: a name in isolation carries less than a name in context. clients.pays is French for country and part of an address — the classifier does not know it is looking at an address table. tabEmployee.branch is personal data in an HR schema and infrastructure anywhere else. A person reading the table knows; a rule reading one column name at a time does not.

That is the real ceiling of schema-name classification, stated as plainly as I can. It is high enough to be useful — the map arrives in minutes and most of it is right — and low enough that the output is labelled DRAFT, with every flag carrying its own explanation so a person can overrule it.

What this buys you

The classifier is the engine of a data catalog — one command, Node, no Docker — that maps where data lives across your sources in about five minutes and flags the columns that look like personal data, each flag showing how it was recognised. For companies that also have an EU compliance duty, it drafts the GDPR Article 30 record from that same map. The draft is exactly that: purpose, lawful basis and retention are decisions, not schema facts, and nothing the tool derives is ever marked confirmed. A person does that, or it stays flagged.

If you have column names that would fool it — creative, abbreviated, or in a language it ships but I have not measured — open an issue with them. A miss with a real name attached is worth more to me than a point of benchmark recall.

See what it finds in your schema.

One command, on your machine. The scan reads names and types only, and talks to nothing but the sources you configure.

$ npx regixo start