Ninety-one ways to check one number

9 min readhushgate

A German account number carries a check digit, but which arithmetic produced it is a property of the bank, not the number. Closing that gap in hushgate meant shipping the Bundesbank’s directory — and turning the library I chose not to depend on into the thing that tests me.

hushgate reads an IBAN with no trouble at all. Move the first four characters to the end, expand the letters to digits, take the whole thing modulo 97, and a real IBAN leaves a remainder of exactly 1. One rule, no data, no maintenance. It is the reason the detector can claim a twenty-two-character run in a sentence and be right essentially always.

Then somebody writes the old form instead.

Bitte überweisen Sie auf Kto. 532013000, BLZ 37040044.
what actually turns up in a German invoice

That went through untouched. Not because anybody decided it should — because there was nothing to decide on. The pre-IBAN pair has no checksum over the whole string, no fixed length, and no prefix announcing what it is. Nine digits beside eight digits is also an order number beside a customer number, and a detector that reports those is a detector the operator switches off inside an hour.

The check digit belongs to the bank

Here is the part I had wrong in my head for a while. A German account number does carry a check digit. What it does not carry is any indication of how that digit was calculated — because the calculation is a property of the bank, not of the number. The Bundesbank publishes about a hundred and fifty Prüfzifferberechnungsmethoden and assigns one to each bank code.

So an account number alone cannot be checked at all. Every ten-digit string is a valid account somewhere. The pair is what carries information, and the pair is checkable in two independent ways at once: is 37040044 a bank code the Bundesbank actually published, and does 532013000 satisfy that particular bank’s arithmetic?

For that pair the answers are yes and yes — Commerzbank Köln, Verfahren 13, weights 2 and 1 over digits two through seven with the check digit in position eight. Change one digit inside that window and the reading collapses. Two confirmations from one line of an invoice is what makes it safe to redact an unlabelled pair, and it is the whole design.

The library I did not take

There is an npm package that does this. It is MIT-licensed, has no runtime dependencies of its own, implements all hundred and forty-five methods, and ships the bank directory refreshed every quarter. It is good work and I read a lot of it.

hushgate has zero runtime dependencies, and that sentence is on the banner. For a proxy whose entire pitch is that personal data does not leave the machine, the supply chain is not a footnote — it is a thing an auditor asks about, and every honest answer gets longer with each package added. So the choice was to implement the methods from the Bundesbank specification, or to accept the first dependency and rewrite the claim.

I did not want the dependency. That is not the same as wanting to be alone with a hundred and fifty pieces of arithmetic and my own confidence.

So the package became the adversary instead of the supplier. I wrote the methods from the specification, pinned them against the Bundesbank’s own 457 published Testkontonummern, and then ran both implementations against each other over roughly a million account-and-bank-code pairs — random ones, short ones, ones with leading zeros, every first digit, every method a live bank code names.

Ninety of the ninety-nine methods agree on every single pair. That is a far stronger statement than my tests alone could make, and it cost one afternoon and no dependency.

The nine that disagree

The interesting part is the nine that did not agree, because a disagreement is not automatically my bug. Each one had to be resolved against the specification rather than against whichever implementation I trusted more that hour. In all nine, the German text settles it plainly — and settles it my way.

Die Einerstelle der Summe wird von 10 subtrahiert.
Das Ergebnis ist die Prüfziffer.
(Ist das Ergebnis = 10, ist die Prüfziffer = 0)
Verfahren 29, from the Bundesbank spec

The parenthesis is the whole disagreement. Compute ten minus the units digit and stop, and an account whose check digit is zero gets rejected forever. Four of the nine are that sentence, or the same idea under modulus 7 in Verfahren 93. One is a rule about how many leading zeros open a variant, where the spec says exactly two and the other implementation reads at least two. One is an exception the spec ties to position one, applied by the other implementation to whatever became position one after an earlier rule removed a digit.

None of these is a criticism of that package. They are the residue you get any time two people read the same hundred pages of German banking arithmetic. What matters is that they are now written down, in the module header, each with the sentence that decides it — so the next person who runs this comparison does not have to rediscover all nine.

They all fall the same way, too: hushgate accepts an account the other rejects. For a redaction proxy that is the safe direction. A wrong accept costs one extra candidate span for the resolver to weigh. A wrong reject costs an account number leaving the building.

The bug the benchmark found

The detector reads UK sort codes as well, and for each one it asked a simple question: is there an account number just after this? I answered it by scanning the list of digit runs in the document. Which is fine, until the document is nothing but sort codes.

'53-20-13 '.repeat(111111)

before:  did not finish in two minutes
after:   376 ms
1 MB of a shape no real request has

Quadratic, in the most ordinary way there is, and no regex involved — just a lookup written as a scan. It is a map now. The thing I would keep from this: I only found it because the benchmark included a body built to be hostile rather than a body built to be typical. Typical bodies had been running in 267 milliseconds per four megabytes the whole time, cheerfully.

And the regression test that guards it was watched failing against the old code before it was trusted passing against the new. A performance test you have never seen go red is a performance test that measures your patience, not your code.

What it still will not do

A lone account number with no bank code beside it and no label in front of it is not reported. Six to ten bare digits is the shape of every order number, delivery note and customer reference in German business correspondence, and there is no arithmetic that separates them — the whole point of this piece is that the arithmetic lives in the bank code. If your own forms use a word the built-in label list does not know, the config file takes it.

The bank directory is a snapshot, and the Bundesbank republishes it quarterly. A bank added after the snapshot is not recognised, which costs a finding, never a false one — a script regenerates the table, and an operator who cannot wait for a release can hand in their own. About a fifth of banks publish no check-digit method at all, and for those the pair rests on the bank code alone.

The German evasion corpus — 299 spellings of personal data written the way somebody who does not want to be redacted would actually write them — went from 196 caught to 200. Four cases for a directory, ninety-one methods and a differential harness. That ratio is roughly what deterministic detection costs once the easy formats are done, and it is worth saying out loud: the remaining hundred are mostly prose, and prose is where this approach ends.

// the project

hushgate

Use the best LLM APIs. Keep the data in Europe. Source-available under the Business Source License 1.1 — not an OSI open-source licence.