PiiCatalog

Regexes and validators for 29 personal identifiers: email addresses, phone numbers, payment cards, IBANs, IP addresses, dates of birth, EU VAT numbers, and national identity, tax and social security numbers from 18 European countries and the US. Each value a regex finds is confirmed by a check digit or structure check where the identifier has one, and a scanner finds them in a text, most of them in one shared regex pass. No processes, no state and no runtime dependencies: nothing is started, cached or logged.

Mix.install([{:pii_catalog, "~> 0.1"}])

scanner = PiiCatalog.compile()
PiiCatalog.find(scanner, "Pay to DE89 3704 0044 0532 0130 00 by Friday")
#=> [%PiiCatalog.Match{name: :iban, value: "DE89 3704 0044 0532 0130 00", offset: 7, length: 27}]

Identifiers

"Checked" is what a candidate must pass after the regex finds it. "Keyword" says whether the regex matches only directly after a word naming the identifier. Each vector file holds values that must be found, values of the right shape that fail the check, and look-alikes that must not be found.

Name Identifier Regions Checked Keyword Vectors
email Email address Global Format only no email.exs
phone_us US phone number United States Structure only: Area code and exchange that can exist no phone_us.exs
phone_intl International phone number Global Format only no phone_intl.exs
ssn US Social Security number United States Structure only: Excludes numbers never issued no ssn.exs
credit_card Payment card number Global Check digit: Luhn check digit no credit_card.exs
iban IBAN (bank account number) Global, European Union Check digit: Length for the country and mod-97 check digits no iban.exs
ip_address IPv4 address Global Structure only: Each part 0–255 no ip_address.exs
date_of_birth Date of birth Global Format only required, part of the match date_of_birth.exs
cpr_dk Danish CPR number Nordics, European Union, Denmark Structure only: Date of birth, after the word CPR required cpr_dk.exs
hetu_fi Finnish personal identity code (henkilötunnus) Nordics, European Union, Finland Check digit: Date of birth and check character no hetu_fi.exs
kennitala_is Icelandic kennitala Nordics, Iceland Structure only: Date of birth and century digit, after the word kennitala required kennitala_is.exs
fodselsnummer_no Norwegian national identity number (fødselsnummer) Nordics, Norway Check digit: Date of birth and two mod-11 check digits no fodselsnummer_no.exs
personnummer_se Swedish personal identity number (personnummer) Nordics, European Union, Sweden Check digit: Date of birth and Luhn check digit no personnummer_se.exs
svnr_at Austrian social security number (SVNR) European Union, Austria Check digit: Check digit, after the word SVNR required svnr_at.exs
rrn_be Belgian national register number European Union, Belgium Check digit: Month of birth and mod-97 check digits no rrn_be.exs
steuer_id_de German tax ID (Steuer-ID) European Union, Germany Check digit: ISO 7064 check digit and digit pattern no steuer_id_de.exs
isikukood_ee Estonian personal identification code (isikukood) European Union, Estonia Check digit: Date of birth and check digit, after the word isikukood required isikukood_ee.exs
dni_es Spanish national identity number (DNI) European Union, Spain Check digit: Check letter (mod 23) no dni_es.exs
nie_es Spanish foreigner identity number (NIE) European Union, Spain Check digit: Check letter (mod 23) no nie_es.exs
nir_fr French social security number (NIR) European Union, France Check digit: Sex, month and mod-97 key no nir_fr.exs
ppsn_ie Irish PPS number European Union, Ireland Check digit: Check letter (mod 23) no ppsn_ie.exs
codice_fiscale_it Italian tax code (codice fiscale) European Union, Italy Check digit: Date of birth and check character no codice_fiscale_it.exs
bsn_nl Dutch citizen service number (BSN) European Union, Netherlands Check digit: 11-check, after the word BSN required bsn_nl.exs
pesel_pl Polish PESEL number European Union, Poland Check digit: Date of birth and check digit no pesel_pl.exs
nif_pt Portuguese tax number (NIF) European Union, Portugal Check digit: Mod-11 check digit, after the word NIF required nif_pt.exs
vat_eu EU VAT number European Union Partly check digit: Check digits in 21 member states; format only in BG, CY, EL, LV, MT and RO, for Spanish legal entities and for the French key; date only for Czech nine-digit numbers no vat_eu.exs
ahv_ch Swiss social security number (AHV/AVS) Switzerland Check digit: 756 prefix and EAN-13 check digit no ahv_ch.exs
nino_gb UK National Insurance number United Kingdom Structure only: Issued prefix, after the words National Insurance required nino_gb.exs
nhs_gb UK NHS number United Kingdom Check digit: Mod-11 check digit, after the word NHS required nhs_gb.exs

The table is generated from PiiCatalog.patterns/0 by this repository's pii_catalog.readme Mix task; a test fails when it drifts.

Usage

Add it to your dependencies:

def deps do
  [{:pii_catalog, "~> 0.1"}]
end

Compile a scanner once and keep it. Compiling one with every pattern takes about as long as scanning a few kilobytes of text, a cost worth paying once rather than on every call; a scanner is an immutable value you can store in a process, an ETS table or :persistent_term.

scanner = PiiCatalog.compile([:email, :iban, :personnummer_se])
nordics = PiiCatalog.compile(PiiCatalog.in_region(:nordics))

PiiCatalog.scan(scanner, text)  # every value of the right shape
PiiCatalog.find(scanner, text)  # only those that pass their check
PiiCatalog.valid?(:iban, "DE89 3704 0044 0532 0130 00")  #=> true
Function Returns
PiiCatalog.patterns/0 Every %PiiCatalog.Pattern{}, in catalog order.
PiiCatalog.names/0 Every pattern name, such as :iban. Names never change once released.
PiiCatalog.get/1, fetch!/1 One pattern by name (atom or string).
PiiCatalog.regions/0, region_label/1 The regions patterns are tagged with, with display names.
PiiCatalog.in_region/1, groups/0 The patterns tagged with a region; the patterns grouped by the region they are listed under.
PiiCatalog.compile/1 An opaque %PiiCatalog.Scanner{} for some or all patterns.
PiiCatalog.scan/2 Candidate matches, before validation, as %PiiCatalog.Match{}.
PiiCatalog.find/2 The candidates that pass their pattern's validator.
PiiCatalog.valid?/2 Whether a value passes a pattern's validator. It does not check the format.
PiiCatalog.Validators The checks themselves: Luhn, mod 11, mod 97, ISO 7064 and the rest.
PiiCatalog.ScanError Raised by scan/2 and find/2 when a text could not be scanned to the end.

A %PiiCatalog.Match{} has the pattern name, the matched value, and its offset and length in bytes. scan/2 and find/2 report matches grouped by pattern in the order the scanner was compiled with, left to right within each pattern, with no match starting inside the previous match of the same pattern. Matches of different patterns can overlap.

scan/2 and find/2 raise PiiCatalog.ScanError when the regex engine gives up on a pattern before the end of the text: when it reaches its match limit or its recursion limit. The exception has two fields: patterns, the names of the patterns being scanned (for the shared pass, every pattern in it), and reason, the engine's error, such as :match_limit. The results are never silently partial: either the whole text was scanned and every match is returned, or the call raises and returns nothing. Catch the error and decide what to do with the text, such as refusing it or redacting it whole. The catalog's tests run its patterns on crafted inputs that make regexes backtrack, such as a keyword followed by thousands of spaces; a pattern of your own can still reach the limit.

Precision

A match means a value has the shape of an identifier and passes its check. It does not mean the value was issued, belongs to anyone, or is personal data where it appears. This package makes no claim of precision or recall on real text. What follows is derived from the code; each claim has a test in test/limits_test.exs.

What a check digit proves

A check digit is computed from the rest of the value. When it matches, the value is well-formed: it was not mistyped in the ways the check catches. It does not prove that the number was issued, that it is still in use, or that it belongs to the person named next to it. Anyone can compute a valid check digit for an invented number.

Luhn, used by payment cards and the Swedish personnummer, catches every single-digit error and every swap of two adjacent digits except 09 and 90. For any body exactly one of the ten possible last digits passes, so 1 in 10 random digit strings of the right length and prefix passes. Checks with more possible check values, such as two digits mod 97 or a check letter, pass fewer random values, and several identifiers also check a date of birth, but none tells a real value from a well-formed invented one.

These identifiers have a check digit, letter or character:

Check digits for some values, structure for others

EU VAT numbers are checked by the rules each member state publishes. Six have no check here (Bulgaria, Cyprus, Greece, Latvia, Malta and Romania), so any number of their format passes, and so does any Spanish legal entity's number and any French key in front of a SIREN that passes Luhn. Czech numbers of nine digits are the birth numbers of people born before 1954, which have no check digit: only their date is checked. SOURCES.md lists the source for each country, and says for each of the six why it has no check.

Structure only, no check digit

These are confirmed by a structure check only: a date that exists, or ranges that are never issued.

A US phone number is any ten digits whose first and fourth digits are 2 to 9, which 64 in 100 ten-digit numbers are. The SSN check only refuses area 000 and 666, group 00 and serial 0000, and area 900 to 999 outside the ITIN groups. A Danish CPR number is not guaranteed to pass the modulus-11 check since 2007, and an Icelandic kennitala issued since 18 February 2026 has no check digit, so only their date is checked. These two are matched only after a word naming them.

Format only, no validator

A context keyword is required

These match only directly after a word naming the identifier, in one of the forms listed. Between the word and the value there may be only spaces and tabs and at most one : or #; for bsn_nl and nif_pt that one character may also be a .. A . that ends an abbreviation is part of the word (kt., CPR-nr., NI no., SV-Nr., N.I.F.), and so is one after Kennitala and kennitölu; NINO. and SVNR. are read as NI no. and SV-Nr.. For date_of_birth there may be one or more spaces, tabs or colons. A keyword in another language, in another form, with other text before the value or on the line before is not seen. The keyword is not part of the match, except for date_of_birth.

Known false positives

Known false negatives

When a scan raises

scan/2 and find/2 raise PiiCatalog.ScanError, with the patterns being scanned and the engine's reason, when the regex engine gives up before the end of the text (its match or recursion limit). They never return the matches found before that point as if they were all of them. See Usage.

How matching works

Patterns are compiled without the u flag, so they match bytewise and hold on text that is not valid UTF-8. Their boundaries ask only about ASCII word characters, so a value next to the bytes of a non-ASCII letter is found. Offsets and lengths are in bytes.

Most patterns start the same way, with no ASCII word character before and then a digit or a capital letter. PiiCatalog.Scanner scans all of those in one pass, trying each as a lookahead at every position where such a match can start. A pattern led by a literal or a keyword is scanned on its own, and skipped when the text lacks what its requires field names. A pattern of your own is always scanned on its own, so it cannot change what the catalog patterns find, and a PiiCatalog.ScanError raised on it names it alone. For each pattern the result is the same as Regex.scan/3 on its own regex; a property test checks this on generated text.

Three validators compare a date of birth with today's date (pesel_pl, isikukood_ee, rrn_be), so their answer for a given value can change over time.

Test vectors

test/vectors/<name>.exs holds, for each identifier:

test/vectors/noise.exs is ordinary log and prompt text in which no pattern may match. The values are published test numbers, numbers generated to pass or fail a check, or invented. None was taken from a real person, but a generated value that passes its check may still have been issued to someone. SOURCES.md, under "Test values", says how most values for EU VAT numbers and eight national identifiers were generated.

Reporting a missed or false identifier

See SECURITY.md. Never include real personal data in a report: use an invented value that is found, or missed, the same way.

License

Apache License 2.0. See LICENSE and NOTICE.