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

snippets/check-a-company-identification-number/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/check-a-company-identification-number/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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

  5. 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.

  6. 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

  1. RecommendedN0Check digit, through a standard-number libraryFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata