Check a company identification number
Check that a SIREN or a SIRET typed into a form is well-formed, before storing it.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the check is an arithmetic identity: there is no approximation to improve on, only an exception to know about, and the library carries it — on 35600000009075, La Poste’s Rennes establishment, a hand-written Luhn key answers no where the library answers yes. The three levels above are shut for the same reason: you do not learn a function you can compute. So the way out of this entry is not up the ladder; it is the Sirene register, which answers the question the key does not ask.
Check digit, through a standard-number library
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Check a French company identification number, offline and exactly.
Rung N0. A SIREN is nine digits, a SIRET fourteen, and the last digit of each
is computed from the ones before it. Checking it is arithmetic: the answer is
exact, it is the same every time, and it costs nothing.
The arithmetic is not what you write. A standard-number library carries it —
`python-stdnum` here, `stdnum-js` in JavaScript — and with it the exception a
hand-written Luhn gets wrong: every establishment of La Poste shares the SIREN
356000000, and their SIRETs do not satisfy Luhn. The rule INSEE gives for them
is that the plain sum of the fourteen digits is a multiple of five.
35600000009075, the Rennes establishment, fails Luhn and is valid.
What the key proves is narrow, and the report says which one was applied so the
caller can tell. A number can be well-formed and belong to nobody: 000000000
passes. Whether the company exists, and is still trading, is a lookup in the
Sirene register, not a harder computation.
"""
from __future__ import annotations
from stdnum.fr import siren, siret
# The separators people paste around these numbers: the ordinary space, the
# non-breaking ones a spreadsheet inserts, the full stop, the hyphens. Anything
# else surviving the clean-up is refused rather than quietly dropped, which is
# what turns a mistyped number into a different valid one.
SEPARATORS = " . -‐‑–"
# Nine digits, or fourteen. Sixty-four characters leaves room for every
# separator anyone has pasted and refuses a pasted paragraph.
MAX_CHARACTERS = 64
VALIDATORS = {"SIREN": (9, siren.is_valid), "SIRET": (14, siret.is_valid)}
BY_LENGTH = {9: "SIREN", 14: "SIRET"}
def check_company_number(raw, *, expected: str | None = None) -> dict:
"""
Say whether `raw` carries a well-formed SIREN or SIRET.
`expected` is the kind the caller is asking for, "SIREN" or "SIRET". Left
out, the length decides — the only reading there is, since the two lengths
do not overlap — and `kind` in the report says which one was checked.
Nothing here raises on bad input: a validator that throws in a request path
leaves the form with no answer to show. Every refusal comes back as
`valid: False` with a `reason` in plain words.
"""
if not isinstance(raw, str):
return _report(False, None, "", f"a company number is text, not {type(raw).__name__}")
if len(raw) > MAX_CHARACTERS:
return _report(False, None, "", f"longer than {MAX_CHARACTERS} characters")
compact = "".join(character for character in raw if character not in SEPARATORS)
# `str.isdigit` is true of Arabic-Indic and other decimal digits, which the
# checksum cannot be computed on; ASCII is what the register uses.
if not compact or not compact.isascii() or not compact.isdigit():
return _report(False, None, compact, "not digits and separators only")
kind = expected or BY_LENGTH.get(len(compact))
if kind not in VALIDATORS:
return _report(False, None, compact, f"{len(compact)} digits: expected nine or fourteen")
length, is_valid = VALIDATORS[kind]
if len(compact) != length:
return _report(False, kind, compact, f"{kind} is {length} digits, got {len(compact)}")
if not is_valid(compact):
return _report(False, kind, compact, f"the check digit of this {kind} does not match")
return _report(True, kind, compact, None)
def _report(valid: bool, kind: str | None, compact: str, reason: str | None) -> dict:
return {"valid": valid, "kind": kind, "compact": compact, "reason": reason}JavaScript
/**
* Check a French company identification number, offline and exactly.
*
* Rung N0. A SIREN is nine digits, a SIRET fourteen, and the last digit of
* each is computed from the ones before it. Checking it is arithmetic: the
* answer is exact, it is the same every time, and it costs nothing.
*
* The arithmetic is not what you write. A standard-number library carries it —
* `stdnum-js` here, `python-stdnum` in Python — and with it the exception a
* hand-written Luhn gets wrong: every establishment of La Poste shares the
* SIREN 356000000, and their SIRETs do not satisfy Luhn. The rule INSEE gives
* for them is that the plain sum of the fourteen digits is a multiple of five.
* 35600000009075, the Rennes establishment, fails Luhn and is valid.
*
* What the key proves is narrow, and the report says which one was applied so
* the caller can tell. A number can be well-formed and belong to nobody:
* 000000000 passes. Whether the company exists, and is still trading, is a
* lookup in the Sirene register, not a harder computation.
*/
import { stdnum } from 'stdnum';
const { siren, siret } = stdnum.FR;
// The separators people paste around these numbers: the ordinary space, the
// non-breaking ones a spreadsheet inserts, the full stop, the hyphens.
// Anything else surviving the clean-up is refused rather than quietly dropped,
// which is what turns a mistyped number into a different valid one.
export const SEPARATORS = ' . -‐‑–';
// Nine digits, or fourteen. Sixty-four characters leaves room for every
// separator anyone has pasted and refuses a pasted paragraph.
export const MAX_CHARACTERS = 64;
const VALIDATORS = {
SIREN: [9, (value) => siren.validate(value).isValid],
SIRET: [14, (value) => siret.validate(value).isValid],
};
const BY_LENGTH = { 9: 'SIREN', 14: 'SIRET' };
/**
* Say whether `raw` carries a well-formed SIREN or SIRET.
*
* `expected` is the kind the caller is asking for, 'SIREN' or 'SIRET'. Left
* out, the length decides — the only reading there is, since the two lengths
* do not overlap — and `kind` in the report says which one was checked.
*
* Nothing here throws on bad input: a validator that throws in a request path
* leaves the form with no answer to show. Every refusal comes back as
* `valid: false` with a `reason` in plain words.
*
* @param {string} raw
* @param {{expected?: 'SIREN'|'SIRET'}} [options]
*/
export function checkCompanyNumber(raw, { expected } = {}) {
if (typeof raw !== 'string') {
return report(false, null, '', `a company number is text, not ${kindOf(raw)}`);
}
if (raw.length > MAX_CHARACTERS) {
return report(false, null, '', `longer than ${MAX_CHARACTERS} characters`);
}
const compact = [...raw].filter((character) => !SEPARATORS.includes(character)).join('');
// Only ASCII digits: the checksum cannot be computed on Arabic-Indic or
// other decimal digits, and the register uses ASCII.
if (!/^[0-9]+$/.test(compact)) {
return report(false, null, compact, 'not digits and separators only');
}
const kind = expected ?? BY_LENGTH[compact.length];
if (!(kind in VALIDATORS)) {
return report(false, null, compact, `${compact.length} digits: expected nine or fourteen`);
}
const [length, isValid] = VALIDATORS[kind];
if (compact.length !== length) {
return report(false, kind, compact, `${kind} is ${length} digits, got ${compact.length}`);
}
if (!isValid(compact)) {
return report(false, kind, compact, `the check digit of this ${kind} does not match`);
}
return report(true, kind, compact, null);
}
const kindOf = (value) => (value === null ? 'null' : typeof value);
const report = (valid, kind, compact, reason) => ({ valid, kind, compact, reason });Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
Every output is the snippet’s own, run when the site is built. Nothing is written by hand, and nothing leaves the machine: the check is local.
A SIREN pasted from a company registry extract
Given
732 829 320
What it returns
{ "valid": true, "kind": "SIREN", "compact": "732829320", "reason": null }
What it concludes
Well-formed SIREN — 732829320
Nine digits, check digit included: verifying it is an arithmetic identity.
The key says nothing more: it does not know whether this number was ever issued.
The SIRET of the same establishment, hyphen-separated
Given
732-829-320-00074
What it returns
{ "valid": true, "kind": "SIRET", "compact": "73282932000074", "reason": null }
What it concludes
Well-formed SIRET — 73282932000074
Fourteen digits, check digit included: verifying it is an arithmetic identity.
The key says nothing more: it does not know whether this number was ever issued.
A La Poste SIRET, which Luhn alone would reject
Given
35600000009075
What it returns
{ "valid": true, "kind": "SIRET", "compact": "35600000009075", "reason": null }
What it concludes
Well-formed SIRET — 35600000009075
Fourteen digits, check digit included: verifying it is an arithmetic identity.
The key says nothing more: it does not know whether this number was ever issued.
An EU VAT number pasted into the field
Given
FR44732829320
What it returns
{ "valid": false, "kind": null, "compact": "FR44732829320", "reason": "not digits and separators only" }
What it concludes
Refused
not digits and separators only
Nine zeros
Given
000000000
What it returns
{ "valid": true, "kind": "SIREN", "compact": "000000000", "reason": null }
What it concludes
Well-formed SIREN — 000000000
Nine digits, check digit included: verifying it is an arithmetic identity.
The key says nothing more: it does not know whether this number was ever issued.
Here it breaksThe key accepts: 000000000 is well-formed. It does not say a company holds that number, because it looks nothing up — the Sirene register is what answers that.
A SIREN typed with two digits swapped
Given
382 290 401
What it returns
{ "valid": true, "kind": "SIREN", "compact": "382290401", "reason": null }
What it concludes
Well-formed SIREN — 382290401
Nine digits, check digit included: verifying it is an arithmetic identity.
The key says nothing more: it does not know whether this number was ever issued.
Here it breaksThe number meant was 382 209 401; the typist swapped “09” for “90”, and the key still accepts. Luhn catches every single-digit error, and every transposition but that one.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the number is checked inside your own process and is sent to nobody
- The SIREN of a sole trader identifies a natural person: checking it locally discloses nothing, looking it up in the register discloses it
Breaking point
The key says a number is well-formed, not that it names a company: 000000000 passes, and 382 209 401 typed as 382 290 401 passes too, because Luhn catches every single-digit error but not the 09 → 90 swap. The witness is in the same test: on those same numbers, no single-digit error gets through.
When to move up a level
There is no level above this one. The day you need the name, the address or the status — trading or closed — of the company behind the number, the answer is a lookup in the Sirene register, open and key-free, and it takes the number out of your infrastructure.
The four options
- RecommendedN0Check digit, through a standard-number libraryFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn. The last digit is a function of the other eight or thirteen, and that function is published; a classifier trained on labelled numbers would approximate an arithmetic identity that a modulo computes exactly. It would also miss the case the library handles — La Poste’s SIRETs fail the Luhn key and are valid — because they are too rare in any sample for a rule to emerge from them.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with a service to keep running on top. Loading a specialised model to compute a weighted sum of nine digits costs a start-up, memory and a version to track, for an output less reliable than that of a library of a few kilobytes. And what you would really want above the key — whether the company exists — is not inside a model, it is inside a register.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A general-purpose model returns a sentence about a computation it does not perform, and its answer can neither be replayed nor justified: it shows no carry anyone could read back. The question that arises above the key has a public, dated answer — the Sirene register — which a model would replace with a training-time memory whose cut-off it does not know. What would be left for it is finding a number buried in a contract or a legal notice, which is a different need from this one.
Further reading
- INSEE — définition du numéro Siren
- INSEE — définition du numéro Siret
- API Recherche d’entreprises — documentation du point d’accès public
- python-stdnum — le module des numéros français
- stdnum-js — le portage JavaScript, dépôt du projet
- Wikipédia — formule de Luhn, et les erreurs qu’elle ne détecte pas
Where the figures come from
- python-stdnum sur PyPI (consulté) : version 2.2, publiée le 4 janvier 2026, licence LGPL v2 ou ultérieure
- python-stdnum, module stdnum.fr.siret (consulté) : « The Luhn checksum is used to validate the numbers (except for La Poste) »
- stdnum-js, dépôt du projet (consulté) : version 1.12.6 publiée le 1er août 2026, licence MIT, modules fr/siren et fr/siret, exception La Poste comprise
- API Recherche d’entreprises sur data.gouv.fr (consulté) : « L’API étant totalement ouverte d’accès », sans clé, limite de sept appels par seconde
- INSEE, définition du numéro Siren (consulté) : « Le numéro SIREN est un identifiant de neuf chiffres attribué à chaque unité légale »
- Wikipédia, système d’identification du répertoire des établissements (consulté) : l’exception de La Poste, dont les SIRET ont pour règle que la somme des quatorze chiffres soit un multiple de cinq
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.