Engineering notes · Data quality
The check character is not input validation — it is a measuring instrument
A lookup came in for the tax ID B42413283. We answered, truthfully as far as
it went:
Every clause of that is accurate and the whole thing is a lie by omission.
B42413283 is not a tax ID we are missing. It is not a tax ID at all, and no
provider on earth holds it, because the Spanish tax agency could not have
issued it. You can prove that with a pocket calculator.
The last character is not data
Under Orden EHA/451/2008, the ninth character of a Spanish NIF is computed from the eight before it. Double the digits in odd positions and add their digits together, add the digits in even positions, and the check character closes the sum to a multiple of ten:
B 4 2 4 1 3 2 8 ?
↑ ↑ ↑ ↑ doubled: 8, 8, 6, 16→7 = 29
↑ ↑ ↑ as-is: 2 + 1 + 2 = 5
total = 34
check digit = 10 − 4 = 6
So the identifier is B42413286, and the one we were handed ends in 3. One
character out of nine carries no information of its own — it exists solely to
tell you the other eight arrived intact.
Which entities get a digit and which get a letter is its own small thicket:
A, B, E and H take the digit; N, P, Q, R, S and W take a
letter off JABCDEFGHI; C, D, F, G, J, U and V accept either.
Personal IDs are a different algorithm entirely — DNI is eight digits mod 23
against TRWAGMYFPDXBNJZSQVHLCKE, and a NIE is the same with X, Y, Z read
as 0, 1, 2. K, L and M we decline to judge, because the rules for them
have moved and a wrong "invalid" is worse than an honest "unknown".
Validate at the boundary. Never refuse before you look.
The tempting move is to reject a bad check character at the door. We deliberately do not, and the reason is that we hold data harvested from sources that are themselves imperfect. If a mapping in our table has a broken check character, refusing to look it up means refusing to return a row we actually have — a false negative introduced by validation, which is the worst kind.
So the arithmetic runs only after the query misses. A hit is returned no matter what its check character says. A miss gets the honest reason:
{
"error": "nif_check_character_failed",
"nif": "B42413283",
"format_valid": true,
"check_character_valid": false,
"corrected_check_character": "B42413286",
"note": "…for B4241328 it is 6, not 3. So this identifier cannot have been
issued to anyone — it is a typo or a fabrication, not a gap in our
coverage, and no source anywhere will hold it."
}
Zero false negatives, and a caller who now knows to check their own invoice rather than shop for a better data provider.
Then we pointed it at ourselves
That is the boring half. The interesting half is what happens when you run the same arithmetic over your own table.
We hold 527,318 NIF→company mappings, cross-referenced from eighteen sources, because BORME itself prints no tax IDs at all. Nineteen of them fail their own check character. That is 0.0036%, which sounds like a rounding error until you ask where the nineteen are:
| Kind of source | Mappings | Failing | Rate |
|---|---|---|---|
| Official structured registers and catalogues | 432,860 | 0 | 0% |
| Public procurement portals | 91,436 | 4 | 0.004% |
| Companies' own aviso legal pages | 780 | 1 | 0.128% |
| Our own parser over BORME free text | 2,242 | 14 | 0.624% |
Every structured official register and catalogue — 432,860 mappings, GLEIF, BDNS and AEAT among them — is perfectly clean. Not "clean enough". Zero.
And the one source that reads a tax ID out of prose rather than out of a key column carries 14 of the 19 failures, at 173× the overall rate.
That is not a surprise so much as a confirmation with a number attached. When a register stores a NIF, it stores it as an identifier: something validated it on the way in, and it has been a primary key ever since. When our own parser digs a NIF out of a sentence in the gazette — a sentence that has been through typesetting, PDF generation and our extraction regex — nothing between the notary and us ever checked the arithmetic. The check character is the only place that corruption becomes visible.
Three of the four procurement-portal failures are a different problem
altogether. B0011479C sits on MOOG SARL, B0041480E on RÖSLER INTERNATIONAL GMBH & CO KG,
W48083521 on QBE EUROPE SA/NV. Those are not corrupted Spanish tax IDs — they
are foreign identifiers written into a field labelled NIF, by a supplier who had
no Spanish one to put there. The arithmetic cannot tell you that, but it can tell
you where to look.
The trap in "did you mean"
The obvious next feature is suggestions, and it is a better feature than it
first appears. Under the CIF algorithm each body position admits exactly one
replacement digit that closes the arithmetic, so a nine-character identifier has
precisely eight single-character corrections — a short list, not a search space.
B42413283 gives you B42413286 plus seven others.
Then we checked those candidates against the table, and the data argued back. Of the nineteen broken mappings we hold, not one had its check-character fix in our table. Three had an unrelated one-digit neighbour. BROS MAQUINARIA SL had three neighbours at once — CERTIS CORP SL, NARANJAS DEL SURESTE SL and SOFTWARE MAQUINARIA Y MANTENIMIENTO SL — and no reading of those names makes any of them the same company.
The arithmetic is doing exactly what you would expect it to. We hold on the order of half a million identifiers; a prefix admits ten million. A candidate one digit away therefore lands on some real company perhaps one time in twenty, purely by coincidence, and offering seven of them gives coincidence seven chances. So suggestions ship with their provenance attached: the check-character fix is offered plainly, and a one-digit neighbour is labelled as one and carries a warning that it is as likely to be a stranger as your company.
If you cross-reference identifiers from more than one source, run the arithmetic over your whole table once. Ours took under a minute and named the weak source without an argument.