Chapter 4 put regex at 0.171 average F1, last by a wide margin. That is a fair verdict on regex as a general-purpose PII detector and a misleading one as a verdict on rules.
Rules win in one specific place: identifiers with a checksum. Payment cards, ABA routing numbers, National Provider Identifiers, IBANs, ISBNs. These are not natural language. They are designed artefacts with a defined structure and a built-in integrity check, and the integrity check is the thing that turns a guess into near-certainty.
A regular expression that matches sixteen digits will match an order reference, a timestamp concatenation, a product SKU, and a phone number with the spaces stripped. The same expression plus a Luhn check will reject essentially all of those and accept payment cards. You have gone from a pattern match to a validated identification, and you did it in a hundredth of a millisecond.
That distinction is the whole chapter. Match, then validate. A pattern without a validator is a false-positive generator, and false positives are how you end up redacting the order number in an email about an order.
The numbers from the same benchmark:
| Approach | Median latency | Throughput |
|---|---|---|
| Regex + checksum | 0.1 ms | 3,861 texts/sec |
| Presidio | 15.1 ms | 63 texts/sec |
| Piiranha | 118.5 ms | 8.3 texts/sec |
| GLiNER v1 | 161.3 ms | 5.9 texts/sec |
Deterministic detection is free. Not cheap: free, relative to anything else in the request. It runs in-process, with no network call, no container, no GPU, and no failure mode other than your own code. That means you can run it everywhere, on every payload, without a budget conversation. Run it on prompts, on completions, on log lines at the sink, on tool-call arguments, on file metadata. It costs nothing to add another place.
The model-based layer cannot make that claim, which is why Chapter 13 puts these in different positions in the pipeline.
The check digit algorithm behind payment cards, and behind several national identifiers.
public static bool IsLuhnValid(ReadOnlySpan<char> digits)
{
int sum = 0;
bool doubling = false;
for (int i = digits.Length - 1; i >= 0; i--)
{
if (!char.IsDigit(digits[i])) return false;
int d = digits[i] - '0';
if (doubling)
{
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
doubling = !doubling;
}
return digits.Length >= 12 && sum % 10 == 0;
}Used properly, it sits behind a pattern rather than in front of it:
private static readonly Regex CardCandidate =
new(@"\b(?:\d[ -]*?){12,19}\b", RegexOptions.Compiled);
public static IEnumerable<Match> FindPaymentCards(string text)
{
foreach (Match m in CardCandidate.Matches(text))
{
var digits = new string(m.Value.Where(char.IsDigit).ToArray());
if (IsLuhnValid(digits)) yield return m;
}
}The regex is deliberately loose because the validator is strict. That is the correct division of labour: cast wide, then verify. Inverting it, with a tight regex and no validator, gives you the worst of both.
The nine-digit routing number on every US check and ACH transfer. Weights 3, 7, 1 repeating across the nine positions, and the weighted sum must be a multiple of ten.
private static readonly int[] AbaWeights = [3, 7, 1, 3, 7, 1, 3, 7, 1];
public static bool IsAbaRoutingValid(ReadOnlySpan<char> routing)
{
if (routing.Length != 9) return false;
int sum = 0;
for (int i = 0; i < 9; i++)
{
if (!char.IsDigit(routing[i])) return false;
sum += (routing[i] - '0') * AbaWeights[i];
}
return sum % 10 == 0;
}Known-valid test values: 021000021, 011000015.
A routing number identifies a bank rather than a person, so on its own it is not personal data. Next to an account number it is, and the pair is what appears in the ACH details a customer pastes into a support ticket. Detect the routing number, then look for digits near it.
Download the full PDF for free?
Free download — no account required