Validate an imported file against a schema
Check that a JSON file received from a partner follows the agreed contract, and say exactly what is wrong with it.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the contract is already executable and the validator is the same program on both sides of the exchange. Three things in the snippet are worth copying, because none of them is the default: formats are asserted — otherwise jsonschema accepts 2026-02-30 without a word, and ajv refuses to compile the schema — every error is collected rather than the first, and the compiled schema is kept, which gains two orders of magnitude in Python and more in JavaScript on this entry’s measurement. The breaking point is not in the validator but in the data model: a decimal amount has no exact form in a binary float, and it is written as a whole number of cents.
JSON Schema 2020-12, formats asserted, every error returned
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Validate an imported document against the schema it is supposed to follow.
Rung N0. JSON Schema is a published standard, and a validator is a program that
applies it: every failure comes back with the place it happened and the rule it
broke. There is nothing to guess, and nothing to learn — a schema is the
contract, written once, and the same document validates the same way for ever.
`jsonschema` applies it here, `ajv` in JavaScript. Two things they both do that
catch people out, and that this file settles rather than inherits.
`format` is an annotation, not an assertion. The two libraries react to that
differently, and both reactions surprise somebody: `jsonschema` accepts
« 12/01/2026 » and « 2026-02-30 » against `"format": "date"` without a word,
while `ajv` refuses to compile the schema at all until the formats are
registered. Switching the format checker on, below, is what makes the two
agree — and makes both refuse those two dates.
And a validator stops at the first failure unless it is told not to. An import
is checked once and corrected once, so every error is collected, capped, and
the report says how many were left out rather than pretending there were none.
What comes back is a path and a rule name for each failure. The wording of the
message belongs to the library and differs between the two; the path and the
rule are what your code should branch on.
"""
from __future__ import annotations
import json
from jsonschema import Draft202012Validator, FormatChecker
# A file with one systematic mistake produces one error per record. Two hundred
# is enough to see the pattern; the count says how many more there were.
MAX_ERRORS = 200
# Compiling a schema costs far more than checking one document against it, and
# an import checks thousands against the same schema. The compiled form is kept
# here, keyed by the schema itself; the cap is what stops a caller that builds
# a new schema per record from filling memory.
_COMPILED: dict[str, object] = {}
MAX_COMPILED = 32
def validate_import(document, schema) -> dict:
"""
Check `document` against `schema`, and return every failure.
`errors` carries, for each one, the JSON Pointer of the value and the name
of the rule it broke — the two things a caller can act on — plus the
library's own message, which is for a human to read and not for code to
match on.
A schema that is itself invalid is reported as such rather than raised:
on an import path, a broken contract is an incident to log, not a crash.
"""
validator = _compiled(schema)
if validator is None:
return _report(False, [], 0, "this schema is not usable")
try:
failures = sorted(validator.iter_errors(document), key=_order)
except Exception: # noqa: BLE001 - a broken contract is an answer too
return _report(False, [], 0, "this schema is not usable")
kept = [{"path": _pointer(error.absolute_path),
"rule": str(error.validator),
"message": error.message}
for error in failures[:MAX_ERRORS]]
return _report(not failures, kept, len(failures), None)
def _compiled(schema):
"""The compiled validator for this schema, or None when it is not usable."""
try:
key = json.dumps(schema, sort_keys=True)
except TypeError:
return None
if key not in _COMPILED:
try:
# The schema itself is checked first: a rule the standard does not
# define — « objet » for « object » — would otherwise only surface
# as an exception in the middle of a file, on the first value that
# uses it.
Draft202012Validator.check_schema(schema)
_COMPILED[key] = Draft202012Validator(schema, format_checker=FormatChecker())
except Exception: # noqa: BLE001 - a broken contract is an answer too
_COMPILED[key] = None
if len(_COMPILED) > MAX_COMPILED:
del _COMPILED[next(iter(_COMPILED))]
return _COMPILED[key]
def _order(error):
"""
A single order, in both languages: by place in the document, then by rule.
An index sorts as a number, so record 10 comes after record 2 and not
after record 1 as a string comparison would have it.
"""
steps = [(0, step, "") if isinstance(step, int) else (1, 0, str(step))
for step in error.absolute_path]
return (steps, str(error.validator))
def _pointer(path) -> str:
"""The JSON Pointer of a value, which is what both libraries can produce."""
return "".join(f"/{str(step).replace('~', '~0').replace('/', '~1')}" for step in path)
def _report(valid: bool, errors: list, total: int, reason: str | None) -> dict:
return {"valid": valid, "errors": errors, "error_count": total,
"truncated": total > len(errors), "reason": reason}JavaScript
/**
* Validate an imported document against the schema it is supposed to follow.
*
* Rung N0. JSON Schema is a published standard, and a validator is a program
* that applies it: every failure comes back with the place it happened and the
* rule it broke. There is nothing to guess, and nothing to learn — a schema is
* the contract, written once, and the same document validates the same way for
* ever.
*
* `ajv` applies it here, `jsonschema` in Python. Two things they both do that
* catch people out, and that this file settles rather than inherits.
*
* `format` is an annotation, not an assertion. The two libraries react to that
* differently, and both reactions surprise somebody: `jsonschema` accepts
* « 12/01/2026 » and « 2026-02-30 » against `"format": "date"` without a word,
* while `ajv` refuses to compile the schema at all until the formats are
* registered. Registering them, below, is what makes the two agree — and makes
* both refuse those two dates.
*
* And a validator stops at the first failure unless it is told not to. An
* import is checked once and corrected once, so every error is collected,
* capped, and the report says how many were left out rather than pretending
* there were none.
*
* What comes back is a path and a rule name for each failure. The wording of
* the message belongs to the library and differs between the two; the path and
* the rule are what your code should branch on.
*/
// `ajv` still defaults to draft-07; the 2020-12 dialect, the current one, is
// a separate entry point. Importing the default one instead refuses every
// schema that declares 2020-12, which is the shape the standard now publishes.
import AjvModule from 'ajv/dist/2020.js';
import addFormatsModule from 'ajv-formats';
// Both packages are published as CommonJS and carry their class on `default`.
const Ajv = AjvModule.default ?? AjvModule;
const addFormats = addFormatsModule.default ?? addFormatsModule;
// A file with one systematic mistake produces one error per record. Two
// hundred is enough to see the pattern; the count says how many more there were.
export const MAX_ERRORS = 200;
// Compiling a schema costs far more than checking one document against it, and
// an import checks thousands against the same schema. The compiled form is
// kept here, keyed by the schema itself; the cap is what stops a caller that
// builds a new schema per record from filling memory.
const COMPILED = new Map();
export const MAX_COMPILED = 32;
/** The compiled validator for this schema, or null when it is not usable. */
function compiled(schema) {
let key;
try {
key = JSON.stringify(schema) ?? 'undefined';
} catch {
return null;
}
if (!COMPILED.has(key)) {
let validate = null;
try {
// The schema itself is checked first: a rule the standard does not
// define — « objet » for « object » — would otherwise only surface as an
// exception in the middle of a file, on the first value that uses it.
const ajv = new Ajv({ allErrors: true, strictSchema: false });
addFormats(ajv);
validate = ajv.compile(schema);
} catch {
validate = null;
}
COMPILED.set(key, validate);
if (COMPILED.size > MAX_COMPILED) COMPILED.delete(COMPILED.keys().next().value);
}
return COMPILED.get(key);
}
/**
* Check `document` against `schema`, and return every failure.
*
* `errors` carries, for each one, the JSON Pointer of the value and the name
* of the rule it broke — the two things a caller can act on — plus the
* library's own message, which is for a human to read and not for code to
* match on.
*
* A schema that is itself invalid is reported as such rather than thrown: on
* an import path, a broken contract is an incident to log, not a crash.
*
* @param {unknown} document
* @param {object} schema
*/
export function validateImport(document, schema) {
const validate = compiled(schema);
if (!validate) return report(false, [], 0, 'this schema is not usable');
validate(document);
const failures = [...(validate.errors ?? [])].sort(order);
const kept = failures.slice(0, MAX_ERRORS).map((error) => ({
path: error.instancePath,
rule: error.keyword,
message: error.message,
}));
return report(failures.length === 0, kept, failures.length, null);
}
/**
* A single order, in both languages: by place in the document, then by rule.
* An index sorts as a number, so record 10 comes after record 2 and not after
* record 1 as a string comparison would have it.
*/
function order(a, b) {
const steps = (error) => error.instancePath.split('/').slice(1)
.map((step) => step.replace(/~1/g, '/').replace(/~0/g, '~'))
.map((step) => (/^\d+$/.test(step) ? [0, Number(step), ''] : [1, 0, step]));
const left = steps(a);
const right = steps(b);
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
if (i >= left.length) return -1;
if (i >= right.length) return 1;
for (let k = 0; k < 3; k += 1) {
if (left[i][k] < right[i][k]) return -1;
if (left[i][k] > right[i][k]) return 1;
}
}
return a.keyword < b.keyword ? -1 : a.keyword > b.keyword ? 1 : 0;
}
const report = (valid, errors, total, reason) => ({
valid, errors, error_count: total, truncated: total > errors.length, 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.
The same import contract for every case, except the last one which is only an amount. The outputs are the snippet’s own, run when the site is built.
An invoice that follows the contract
Given
Nothing to report: the four fields are there and correctly typed.
What it returns
{ "reference": "FA-000123", "montant": 1250, "date": "2026-01-12", "client": "Boulangerie Martin" }
What it concludes
Valid
Nothing to report: the four fields are there and correctly typed.
In detail
Path Rule Message A field name misspelt
Given
“montnat” instead of “montant”: the closed schema names it instead of losing it.
What it returns
{ "reference": "FA-000123", "montnat": 1250, "montant": 0, "date": "2026-01-12" }
What it concludes
1 error(s)
“montnat” instead of “montant”: the closed schema names it instead of losing it.
In detail
Path Rule Message (the root) additionalProperties must NOT have additional properties A date that does not exist
Given
30 February: refused, because format checking is switched on.
What it returns
{ "reference": "FA-000123", "montant": 1250, "date": "2026-02-30" }
What it concludes
1 error(s)
30 February: refused, because format checking is switched on.
In detail
Path Rule Message /date format must match format "date" Three errors in one record
Given
All returned, ordered by place in the document, not just the first.
What it returns
{ "reference": "FA-12", "montant": "1 250,00", "date": "12/01/2026" }
What it concludes
3 error(s)
All returned, ordered by place in the document, not just the first.
In detail
Path Rule Message /date format must match format "date" /montant type must be number /reference pattern must match pattern "^FA-[0-9]{6}$" An ordinary amount against “multipleOf: 0.01”
Given
The amount 19.99: perfectly ordinary, and refused.
What it returns
19.99
What it concludes
1 error(s)
The amount 19.99: perfectly ordinary, and refused.
In detail
Path Rule Message (the root) multipleOf must be multiple of 0.01 Here it breaks“multipleOf: 0.01” is the natural way to write “euros and cents”, and it refuses 1,363 of the 10,000 amounts from 0.00 to 99.99 — among them 19.99, 4.35 and 1,234.56 — because a JSON number is a binary float and 0.01 has no exact form in one. The answer is not another validator: it is writing the amount as a whole number of cents, which the same 10,000 values pass without a single error.
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 file is read inside your own process and is sent to nobody
- The error report carries the refused values: what it logs is what the file held
Breaking point
A JSON number is a binary float, and “multipleOf: 0.01” — the natural way to write an amount in euros and cents — refuses 1,363 of the 10,000 amounts from 0.00 to 99.99, among them 19.99, 4.35 and 1,234.56. The witness is in the same test: those same 10,000 values, written as whole numbers of cents, all pass.
When to move up a level
There is no level above this one. The day the rule to check is no longer in the file — does this company number exist, is this customer up to date on payments — it is no longer schema validation, it is a lookup.
The four options
- RecommendedN0JSON Schema 2020-12, formats asserted, every error returnedFree · 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 schema is the knowledge, written by hand because it is an agreement between two parties, and the validator only applies it. A classic model trained on files labelled valid or not would learn the shape of the files you have already received, which is the opposite of the need: a contract exists precisely to refuse what you have never seen.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with an objection about shape: this level has to return an exact path — “/4321/montant” — and the name of a rule, because that is what an operator fixes. A small specialised model returns a score over a document, not an address inside it, and you would have to write the schema anyway to know what to train it on.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A general-purpose model can read a schema and a document, and it will return a plausible opinion on whether they agree. What this entry holds against it is elsewhere: you would have to send it the file — a partner’s data, often personal — pay one call per record across batches of tens of thousands of rows, and accept that two passes over the same file may not return the same list of errors. The validator, meanwhile, is the same program the partner who produced the file ran.
Further reading
- JSON Schema — la spécification 2020-12 et son vocabulaire de validation
- jsonschema — la bibliothèque Python
- ajv — la bibliothèque JavaScript, et ses points d’entrée par dialecte
- RFC 6901 — JSON Pointer, la façon de nommer une valeur dans un document
Where the figures come from
- JSON Schema 2020-12, section 7 (consultée) : « format » est un mot-clé d’annotation par défaut, et son assertion est laissée à l’implémentation
- jsonschema sur PyPI (consulté) : version 4.26.0, publiée le 7 janvier 2026, licence MIT
- ajv, documentation des dialectes (consultée) : le point d’entrée par défaut est draft-07, et le dialecte 2020-12 s’importe depuis « ajv/dist/2020 »
- ajv-formats sur npm (consulté) : version 3.0.1, publiée le 30 mars 2024, licence MIT ; c’est le paquet qui enregistre « date », « email » et les autres
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.