Data Formats (JSON, YAML, TOML, XML, CSV) Module 3 – JSON Schema 2020-12
Your first JSON Schema 2020-12
Write a JSON Schema 2020-12 with type, properties, required and value limits, predict what it accepts, and read every error by its two locations.
What you will learn
- Write a 2020-12 schema with $schema, type, properties, required and value limits
- Predict which values type, integer, enum and the number keywords accept
- Write patterns and length limits that mean the same in Python and JavaScript
- Read a validation error by its instance location and its keyword location
- Improve a schema generated from samples so that it refuses bad data
Before you start
What you’ll learn · Before you start
What you will learn
- Write a 2020-12 schema with $schema, type, properties, required and value limits
- Predict which values type, integer, enum and the number keywords accept
- Write patterns and length limits that mean the same in Python and JavaScript
- Read a validation error by its instance location and its keyword location
- Improve a schema generated from samples so that it refuses bad data
Before you start
On this page
A partner bookshop sends a feed of book listings every night, and one of the 4 listings in the last batch had its
price as the text "39900" instead of the number 39900. The stock report stopped with a TypeError at that line, and
it took a morning to work out which listings were wrong and why.
A JSON Schema writes the rules for a listing down once, as a JSON document of its own, so that any program can check a listing before it uses it and say exactly what is wrong. You will write a schema in the current version, 2020-12, predict which values its keywords accept, read every error by the two places it points at, and tighten a schema generated from samples until it refuses bad data.
A schema is a JSON document, and each keyword checks one thing
Here is the bookshop’s schema for one listing, as the first program holds it. Every member of a schema object is a keyword: A member of a JSON Schema object with a meaning the specification defines, such as type, properties or required. Most keywords check the value they apply to; annotation keywords only describe it., and the value it checks is the instance: The JSON value a schema is applied to: the whole document at first, then each value inside it that a subschema is applied to.:
| Keyword in the listing schema | What it says |
|---|---|
"$schema": "https://json-schema.org/draft/2020-12/schema" |
which version of JSON Schema the keywords follow |
"type": "object" |
the listing must be a JSON object |
"properties": {"sku": {…}, …} |
a subschema for each member, applied when the member is there |
"required": ["sku", "title", …] |
the members that must be there |
"enum": ["paperback", "hardback", "ebook"] |
the value must equal one of these |
"pattern", "minLength", "maximum" and the like |
limits on a string or a number |
"title": "Book listing" |
a label for people; it checks nothing |
$schema names the dialect: The set of JSON Schema keywords a schema may use and what each means, named by the URI in $schema, such as https://json-schema.org/draft/2020-12/schema for version 2020-12., here 2020-12, which the JSON Schema project still lists as the
current version; the one before it was 2019-09. Name it in every schema, because the meaning of some keywords changed
between versions, and a validator that reads $schema knows which meaning you wrote for.
The other keywords each check one thing, and a value is valid only when every keyword beside it holds. properties does
not demand anything on its own: a listing without shelf skips the shelf subschema: A schema inside another JSON Schema, as the value of a keyword such as properties, items or allOf. Its keyword decides which value it is applied to: a member, an element or the same value again., and only required makes a
member compulsory. Keywords such as title, description, default and examples are
annotation: Information a JSON Schema keyword attaches to a value without checking it, such as title, description, default or examples. A validator may collect annotations for an application to use.: they describe the value for people and tools, and the validation
specification gives them no power to reject anything.
Every keyword of this lesson fits in one short function. The validator the programs use is that function, written from the specification, one branch per keyword:
Python · first/minischema.py
"""A validator for the keywords of a first JSON Schema 2020-12: type, enum, const, the string and
number limits, properties and required, each checked the way the specification defines it."""
import json
import re
def kind(value):
"""The JSON type of a parsed value. bool comes before int, because True is an int in Python."""
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
return "array" if isinstance(value, list) else "object"
def has_type(value, name):
if name == "integer": # any number with a zero fractional part, so 2.0 counts
return kind(value) == "number" and (isinstance(value, int) or value.is_integer())
return kind(value) == name
def equal(a, b):
"""JSON equality: the same type and value. 1 equals 1.0, but true never equals 1."""
if kind(a) != kind(b):
return False
if kind(a) == "array":
return len(a) == len(b) and all(equal(x, y) for x, y in zip(a, b))
if kind(a) == "object":
return a.keys() == b.keys() and all(equal(a[k], b[k]) for k in a)
return a == b
def show(value):
"""A value as JSON text for a message; 2.0 is shown as 2, as JavaScript shows it."""
if isinstance(value, float) and value.is_integer():
value = int(value)
return json.dumps(value, ensure_ascii=False)
def step(pointer, token):
"""A JSON Pointer one token deeper (RFC 6901: ~ is written ~0 and / is written ~1)."""
return pointer + "/" + str(token).replace("~", "~0").replace("/", "~1")
def validate(schema, value, at="", where=""):
"""Every error of value against schema, as (instance location, keyword location, message)."""
errors = []
for keyword, rule in schema.items(): # keywords this file does not know are ignored
here = step(where, keyword)
problem = None
if keyword == "type":
names = rule if isinstance(rule, list) else [rule]
if not any(has_type(value, name) for name in names):
problem = f"expected {' or '.join(names)}, got {kind(value)} {show(value)}"
elif keyword == "enum":
if not any(equal(value, option) for option in rule):
problem = f"{show(value)} is not one of {', '.join(show(o) for o in rule)}"
elif keyword == "const":
if not equal(value, rule):
problem = f"{show(value)} is not {show(rule)}"
elif kind(value) == "string":
count = len(value) # characters (code points), as RFC 8259 counts them
if keyword == "minLength" and count < rule:
problem = f"{show(value)} has {count} characters, fewer than {rule}"
elif keyword == "maxLength" and count > rule:
problem = f"{show(value)} has {count} characters, more than {rule}"
elif keyword == "pattern" and not re.search(rule, value):
problem = f"{show(value)} does not match {rule}"
elif kind(value) == "number":
if keyword == "minimum" and value < rule:
problem = f"{show(value)} is below the minimum of {show(rule)}"
elif keyword == "exclusiveMinimum" and value <= rule:
problem = f"{show(value)} is not above {show(rule)}"
elif keyword == "maximum" and value > rule:
problem = f"{show(value)} is above the maximum of {show(rule)}"
elif keyword == "exclusiveMaximum" and value >= rule:
problem = f"{show(value)} is not below {show(rule)}"
elif keyword == "multipleOf" and not float(value / rule).is_integer():
problem = f"{show(value)} is not a multiple of {show(rule)}"
elif kind(value) == "object":
if keyword == "required":
for name in rule:
if name not in value:
errors.append((at, here, f"missing required property {show(name)}"))
elif keyword == "properties":
for name, subschema in rule.items():
if name in value:
errors += validate(subschema, value[name], step(at, name), step(here, name))
if problem:
errors.append((at, here, problem))
return errors
def report(label, schema, value):
"""Prints whether value is valid, or each error with the two places it points at."""
errors = validate(schema, value)
if not errors:
print(f"{label}: valid")
return
print(f"{label}: {len(errors)} error{'s' if len(errors) > 1 else ''}")
for at, where, message in errors:
place = at if at else '""' # the empty pointer names the whole document
print(f" at {place}, keyword {where}: {message}") JavaScript · first/minischema.mjs
// A validator for the keywords of a first JSON Schema 2020-12: type, enum, const, the string and
// number limits, properties and required, each checked the way the specification defines it.
export function kind(value) {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value === 'object' ? 'object' : typeof value; // boolean, number or string
}
export function hasType(value, name) {
// integer: any number with a zero fractional part (JSON.parse already turned 2.0 into 2)
if (name === 'integer') return kind(value) === 'number' && Number.isInteger(value);
return kind(value) === name;
}
// JSON equality: the same type and value. 1 equals 1.0, but true never equals 1.
export function equal(a, b) {
if (kind(a) !== kind(b)) return false;
if (kind(a) === 'array') return a.length === b.length && a.every((x, i) => equal(x, b[i]));
if (kind(a) === 'object') {
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]));
}
return a === b;
}
export const show = (value) => JSON.stringify(value);
// A JSON Pointer one token deeper (RFC 6901: ~ is written ~0 and / is written ~1).
export const step = (pointer, token) => `${pointer}/${String(token).replaceAll('~', '~0').replaceAll('/', '~1')}`;
// Every error of value against schema, as [instance location, keyword location, message].
export function validate(schema, value, at = '', where = '') {
const errors = [];
for (const [keyword, rule] of Object.entries(schema)) { // keywords this file does not know are ignored
const here = step(where, keyword);
let problem = null;
if (keyword === 'type') {
const names = Array.isArray(rule) ? rule : [rule];
if (!names.some((name) => hasType(value, name))) problem = `expected ${names.join(' or ')}, got ${kind(value)} ${show(value)}`;
} else if (keyword === 'enum') {
if (!rule.some((option) => equal(value, option))) problem = `${show(value)} is not one of ${rule.map(show).join(', ')}`;
} else if (keyword === 'const') {
if (!equal(value, rule)) problem = `${show(value)} is not ${show(rule)}`;
} else if (kind(value) === 'string') {
const count = [...value].length; // characters (code points), as RFC 8259 counts them
if (keyword === 'minLength' && count < rule) problem = `${show(value)} has ${count} characters, fewer than ${rule}`;
else if (keyword === 'maxLength' && count > rule) problem = `${show(value)} has ${count} characters, more than ${rule}`;
else if (keyword === 'pattern' && !new RegExp(rule, 'u').test(value)) problem = `${show(value)} does not match ${rule}`;
} else if (kind(value) === 'number') {
if (keyword === 'minimum' && value < rule) problem = `${show(value)} is below the minimum of ${show(rule)}`;
else if (keyword === 'exclusiveMinimum' && value <= rule) problem = `${show(value)} is not above ${show(rule)}`;
else if (keyword === 'maximum' && value > rule) problem = `${show(value)} is above the maximum of ${show(rule)}`;
else if (keyword === 'exclusiveMaximum' && value >= rule) problem = `${show(value)} is not below ${show(rule)}`;
else if (keyword === 'multipleOf' && !Number.isInteger(value / rule)) problem = `${show(value)} is not a multiple of ${show(rule)}`;
} else if (kind(value) === 'object') {
if (keyword === 'required') {
for (const name of rule) if (!Object.hasOwn(value, name)) errors.push([at, here, `missing required property ${show(name)}`]);
} else if (keyword === 'properties') {
for (const [name, subschema] of Object.entries(rule)) {
if (Object.hasOwn(value, name)) errors.push(...validate(subschema, value[name], step(at, name), step(here, name)));
}
}
}
if (problem) errors.push([at, here, problem]);
}
return errors;
}
// Prints whether value is valid, or each error with the two places it points at.
export function report(label, schema, value) {
const errors = validate(schema, value);
if (!errors.length) {
console.log(`${label}: valid`);
return;
}
console.log(`${label}: ${errors.length} error${errors.length > 1 ? 's' : ''}`);
for (const [at, where, message] of errors) {
const place = at || '""'; // the empty pointer names the whole document
console.log(` at ${place}, keyword ${where}: ${message}`);
}
} It walks the schema and the instance together and collects every error instead of stopping at the first. Here it checks the four listings of last night’s feed:
Python · first/listings.py
"""Four book listings from a partner's feed, checked against the bookshop's first schema."""
import json
from minischema import report
SCHEMA = json.loads("""
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Book listing",
"type": "object",
"properties": {
"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"format": {"enum": ["paperback", "hardback", "ebook"]},
"price_paise": {"type": "integer", "exclusiveMinimum": 0},
"stock": {"type": "integer", "minimum": 0},
"shelf": {
"type": "object",
"properties": {
"branch": {"enum": ["Pune", "Kochi", "Indore", "Guwahati", "Mysuru"]},
"aisle": {"type": "integer", "minimum": 1, "maximum": 40}
},
"required": ["branch", "aisle"]
}
},
"required": ["sku", "title", "format", "price_paise", "stock"]
}
""")
LISTINGS = [
'{"sku": "BK-101", "title": "Ponniyin Selvan, part 1", "format": "paperback",'
' "price_paise": 39900, "stock": 12, "shelf": {"branch": "Pune", "aisle": 7}}',
'{"sku": "BK-205", "title": "Malgudi Days", "format": "paperback",'
' "price_paise": "39900", "stock": 4}',
'{"sku": "bk-310", "title": "Aranyak", "format": "hardback", "price_paise": 54900}',
'{"sku": "BK-412", "title": "Gitanjali", "format": "Paperback",'
' "price_paise": 19900, "stock": 3, "shelf": {"branch": "Kochi", "aisle": 41}}',
]
for number, text in enumerate(LISTINGS, start=1):
report(f"listing {number}", SCHEMA, json.loads(text)) Output
listing 1: valid
listing 2: 1 error
at /price_paise, keyword /properties/price_paise/type: expected integer, got string "39900"
listing 3: 2 errors
at /sku, keyword /properties/sku/pattern: "bk-310" does not match ^BK-[0-9]{3}$
at "", keyword /required: missing required property "stock"
listing 4: 2 errors
at /format, keyword /properties/format/enum: "Paperback" is not one of "paperback", "hardback", "ebook"
at /shelf/aisle, keyword /properties/shelf/properties/aisle/maximum: 41 is above the maximum of 40
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 listings.py
JavaScript · first/listings.mjs
// Four book listings from a partner's feed, checked against the bookshop's first schema.
import { report } from './minischema.mjs';
const SCHEMA = JSON.parse(`
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Book listing",
"type": "object",
"properties": {
"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"format": {"enum": ["paperback", "hardback", "ebook"]},
"price_paise": {"type": "integer", "exclusiveMinimum": 0},
"stock": {"type": "integer", "minimum": 0},
"shelf": {
"type": "object",
"properties": {
"branch": {"enum": ["Pune", "Kochi", "Indore", "Guwahati", "Mysuru"]},
"aisle": {"type": "integer", "minimum": 1, "maximum": 40}
},
"required": ["branch", "aisle"]
}
},
"required": ["sku", "title", "format", "price_paise", "stock"]
}
`);
const LISTINGS = [
'{"sku": "BK-101", "title": "Ponniyin Selvan, part 1", "format": "paperback",' +
' "price_paise": 39900, "stock": 12, "shelf": {"branch": "Pune", "aisle": 7}}',
'{"sku": "BK-205", "title": "Malgudi Days", "format": "paperback",' +
' "price_paise": "39900", "stock": 4}',
'{"sku": "bk-310", "title": "Aranyak", "format": "hardback", "price_paise": 54900}',
'{"sku": "BK-412", "title": "Gitanjali", "format": "Paperback",' +
' "price_paise": 19900, "stock": 3, "shelf": {"branch": "Kochi", "aisle": 41}}',
];
LISTINGS.forEach((text, i) => report(`listing ${i + 1}`, SCHEMA, JSON.parse(text))); Output
listing 1: valid
listing 2: 1 error
at /price_paise, keyword /properties/price_paise/type: expected integer, got string "39900"
listing 3: 2 errors
at /sku, keyword /properties/sku/pattern: "bk-310" does not match ^BK-[0-9]{3}$
at "", keyword /required: missing required property "stock"
listing 4: 2 errors
at /format, keyword /properties/format/enum: "Paperback" is not one of "paperback", "hardback", "ebook"
at /shelf/aisle, keyword /properties/shelf/properties/aisle/maximum: 41 is above the maximum of 40
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node listings.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Listing 1 is valid. Listing 2 has the price as a string, the bug that stopped the report, now caught before any program
adds it up. Listing 3 breaks two rules at once, a lower-case SKU and no stock, and listing 4 has a capitalised format
and an aisle beyond the last one, 41 when the branch has 40.
Fix listing 4
Press Edit, change "aisle": 41 to "aisle": 40 in listing 4, and run it again. One error is left, the format,
because each keyword is checked on its own.
type names a JSON type, and integer means no fractional part
type takes one of seven names: null, boolean, object, array, number, string and integer, or a list of
them, such as ["string", "null"] for a value that may be missing as null. Six of them are the types of JSON itself.
The seventh, integer, is not: JSON has a single kind of number, and the validation specification defines an integer
as any number with a zero fractional part. This program runs eight JSON texts through integer and number:
Python · first/integer_rules.py
"""Which JSON values "integer" and "number" accept, then multipleOf on paise and on rupees."""
import json
from minischema import validate
TEXTS = ["2", "2.0", "2.5", "1e2", "-0", "true", '"2"', "null"]
print("JSON text integer number")
for text in TEXTS:
value = json.loads(text)
cells = ["valid" if not validate({"type": t}, value) else "invalid" for t in ("integer", "number")]
print(f"{text:<10} {cells[0]:<8} {cells[1]}")
print()
print("false against enum [0, 1]:", "valid" if not validate({"enum": [0, 1]}, False) else "invalid")
print()
PRICE = {"type": "integer", "multipleOf": 100} # prices in whole rupees, counted in paise
for paise in [39900, 24950]:
errors = validate(PRICE, paise)
print(f"{paise} paise:", "valid" if not errors else errors[0][2])
RUPEES = {"type": "number", "multipleOf": 0.01} # the same rule, in rupees with two decimals
errors = validate(RUPEES, 19.99)
print("19.99 rupees:", "valid" if not errors else errors[0][2])
print("because 19.99 / 0.01 =", 19.99 / 0.01) Output
JSON text integer number 2 valid valid 2.0 valid valid 2.5 invalid valid 1e2 valid valid -0 valid valid true invalid invalid "2" invalid invalid null invalid invalid false against enum [0, 1]: invalid 39900 paise: valid 24950 paise: 24950 is not a multiple of 100 19.99 rupees: 19.99 is not a multiple of 0.01 because 19.99 / 0.01 = 1998.9999999999998
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 integer_rules.py
JavaScript · first/integer_rules.mjs
// Which JSON values "integer" and "number" accept, then multipleOf on paise and on rupees.
import { validate } from './minischema.mjs';
const TEXTS = ['2', '2.0', '2.5', '1e2', '-0', 'true', '"2"', 'null'];
console.log('JSON text integer number');
for (const text of TEXTS) {
const value = JSON.parse(text);
const cells = ['integer', 'number'].map((t) => (validate({ type: t }, value).length ? 'invalid' : 'valid'));
console.log(`${text.padEnd(10)} ${cells[0].padEnd(8)} ${cells[1]}`);
}
console.log();
console.log('false against enum [0, 1]:', validate({ enum: [0, 1] }, false).length ? 'invalid' : 'valid');
console.log();
const PRICE = { type: 'integer', multipleOf: 100 }; // prices in whole rupees, counted in paise
for (const paise of [39900, 24950]) {
const errors = validate(PRICE, paise);
console.log(`${paise} paise:`, errors.length ? errors[0][2] : 'valid');
}
const RUPEES = { type: 'number', multipleOf: 0.01 }; // the same rule, in rupees with two decimals
const errors = validate(RUPEES, 19.99);
console.log('19.99 rupees:', errors.length ? errors[0][2] : 'valid');
console.log('because 19.99 / 0.01 =', 19.99 / 0.01); Output
JSON text integer number 2 valid valid 2.0 valid valid 2.5 invalid valid 1e2 valid valid -0 valid valid true invalid invalid "2" invalid invalid null invalid invalid false against enum [0, 1]: invalid 39900 paise: valid 24950 paise: 24950 is not a multiple of 100 19.99 rupees: 19.99 is not a multiple of 0.01 because 19.99 / 0.01 = 1998.9999999999998
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node integer_rules.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The table answers the question that first schemas get wrong: 2.0, 1e2 and -0 are integers, because each is a
number whose fractional part is zero, while 2.5 is only a number. true is neither, and "2" is a string however
much it looks like a number, which is exactly listing 2’s bug.
The two languages reach the same table by different routes. JSON.parse("2.0") gives the number 2, so JavaScript only
asks Number.isInteger. Python’s json.loads("2.0") gives the float 2.0, so the validator asks is_integer() as well
as checking for an int. Draft-04 had called 1.0 a non-integer, and draft-06 changed that, so an old validator may
still disagree; the specification also asks producers to write integers without a fraction, as 2.
Letting Python's True pass as the integer 1
Wrong: testing isinstance(value, int) for integer, or value in rule for enum. In Python, booleans are a
subtype of integers, so isinstance(True, int) is True and False in [0, 1] is True. Right: test for bool
first, as kind() does, and compare types before values, as equal() does. The program printed false against enum [0, 1]: invalid, as the JSON data model says.
The last lines of the output are about multipleOf, which the next section takes up.
Checkpoint
Check: Keywords, properties and required · type, integer and number
Length counts characters, pattern searches, and bounds are inclusive
Strings and numbers each have their own limits, and each limit has one detail that trips people up.
minLength and maxLength count characters as RFC 8259 defines them, one per Unicode code point. A JavaScript string’s
length counts UTF-16 code units instead, and a character outside the Basic Multilingual Plane, such as an emoji, takes
two of them. The second program measures a gift-set title:
Python · first/strings.py
"""How the length keywords count, and how pattern matches, in a validator written in Python."""
from minischema import show, validate
title = "📚 Gift set"
units = len(title.encode("utf-16-le")) // 2
print(f"{show(title)}: {len(title)} characters, {units} UTF-16 code units")
print("maxLength 10:", "valid" if not validate({"maxLength": 10}, title) else "invalid")
print(f"counting UTF-16 code units instead: {units} is more than 10, so invalid")
CASES = [
("^BK-[0-9]{3}$", ["BK-101", "BK-1011", "BK-101\n"]),
("[0-9]{3}", ["old code BK-1011 (2 copies)"]),
("^BK-\\d{3}$", ["BK-101", "BK-१०१"]),
]
for pattern, texts in CASES:
print()
print("pattern", pattern)
for text in texts:
print(f" {show(text)}:", "match" if not validate({"pattern": pattern}, text) else "no match")
print()
BOUNDED = {"type": "string", "pattern": "^BK-[0-9]{3}$", "maxLength": 6}
for text in ["BK-101", "BK-101\n"]:
failed = [where.split("/")[-1] for _, where, _ in validate(BOUNDED, text)]
print(f"with maxLength 6, {show(text)}:", f"invalid ({', '.join(failed)})" if failed else "valid") Output
"📚 Gift set": 10 characters, 11 UTF-16 code units
maxLength 10: valid
counting UTF-16 code units instead: 11 is more than 10, so invalid
pattern ^BK-[0-9]{3}$
"BK-101": match
"BK-1011": no match
"BK-101\n": match
pattern [0-9]{3}
"old code BK-1011 (2 copies)": match
pattern ^BK-\d{3}$
"BK-101": match
"BK-१०१": match
with maxLength 6, "BK-101": valid
with maxLength 6, "BK-101\n": invalid (maxLength)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strings.py
JavaScript · first/strings.mjs
// How the length keywords count, and how pattern matches, in a validator written in JavaScript.
import { show, validate } from './minischema.mjs';
const title = '📚 Gift set';
const units = title.length; // String length counts UTF-16 code units
console.log(`${show(title)}: ${[...title].length} characters, ${units} UTF-16 code units`);
console.log('maxLength 10:', validate({ maxLength: 10 }, title).length ? 'invalid' : 'valid');
console.log(`counting UTF-16 code units instead: ${units} is more than 10, so invalid`);
const CASES = [
['^BK-[0-9]{3}$', ['BK-101', 'BK-1011', 'BK-101\n']],
['[0-9]{3}', ['old code BK-1011 (2 copies)']],
['^BK-\\d{3}$', ['BK-101', 'BK-१०१']],
];
for (const [pattern, texts] of CASES) {
console.log();
console.log('pattern', pattern);
for (const text of texts) {
console.log(` ${show(text)}:`, validate({ pattern }, text).length ? 'no match' : 'match');
}
}
console.log();
const BOUNDED = { type: 'string', pattern: '^BK-[0-9]{3}$', maxLength: 6 };
for (const text of ['BK-101', 'BK-101\n']) {
const failed = validate(BOUNDED, text).map(([, where]) => where.split('/').at(-1));
console.log(`with maxLength 6, ${show(text)}:`, failed.length ? `invalid (${failed.join(', ')})` : 'valid');
} Output
"📚 Gift set": 10 characters, 11 UTF-16 code units
maxLength 10: valid
counting UTF-16 code units instead: 11 is more than 10, so invalid
pattern ^BK-[0-9]{3}$
"BK-101": match
"BK-1011": no match
"BK-101\n": no match
pattern [0-9]{3}
"old code BK-1011 (2 copies)": match
pattern ^BK-\d{3}$
"BK-101": match
"BK-१०१": no match
with maxLength 6, "BK-101": valid
with maxLength 6, "BK-101\n": invalid (pattern, maxLength)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node strings.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
"📚 Gift set" is 10 characters and 11 code units, so it meets "maxLength": 10, and a validator that used .length
would refuse it. The lesson’s validator counts [...value].length in JavaScript and len(value) in Python, which both
count code points.
pattern is a regular expression in the ECMA-262 dialect, the one JavaScript uses, and it is not anchored: it only has
to match somewhere in the string. That is why [0-9]{3} matched "old code BK-1011 (2 copies)". Anchor it, as the
listing schema does with ^BK-[0-9]{3}$, when the whole value must have the shape.
Run the same program in its two tabs and two pattern results differ. The Python tab says "BK-101\n" matches ^BK-[0-9]{3}$,
because in Python’s re the $ also matches just before a newline at the end. It also says "BK-१०१" matches
^BK-\d{3}$, because Python’s \d matches any Unicode decimal digit. In ECMA-262 $ is the end of the input and \d
is [0-9], so the JavaScript tab says no match to both. A validator written in Python that hands pattern straight to
re therefore accepts values that the specification’s dialect refuses.
Patterns that mean the same everywhere
Write [0-9] instead of \d, anchor with ^ and $, and add minLength and maxLength when the length is fixed.
The last two lines of the output show the bound at work: with "maxLength": 6, both languages refuse "BK-101\n",
Python through maxLength alone.
For numbers, minimum and maximum are inclusive and exclusiveMinimum and exclusiveMaximum are not: "minimum": 0
lets a stock of 0 through, and "exclusiveMinimum": 0 makes a price of 0 fail. Since draft-06 the exclusive keywords are
numbers of their own; in draft-04 they were true or false beside minimum. multipleOf asks that the value divided
by it is an integer. On whole minor unit: The smallest unit of a currency that amounts are counted in, such as the cent or the paisa. Storing amounts as whole minor units keeps every sum exact. More in “Numbers: precision, big integers and decimals” that is exact: "multipleOf": 100 on
price_paise accepted 39900 and refused 24950. On rupees with decimals it fails, because 19.99 ÷ 0.01 is
1998.9999999999998 in binary floating point, so 19.99 is refused as a multiple of 0.01. Keep amounts in integer paise
and the problem never arises.
Checkpoint
Check: Length, pattern and number limits
Every error names a place in the document and a place in the schema
Each error the programs printed has two pointers. The instance location: A JSON Pointer to the value inside the document that a validation error or annotation is about, such as /shelf/aisle. The empty pointer names the whole document. is a
JSON Pointer: A string that names one value inside a JSON document, such as /items/0/qty, defined by RFC 6901. Inside a name, ~ is written ~0 and / is written ~1. More in “JSON interop: duplicates, key order, I-JSON” to the value in the document, such as /shelf/aisle. The
keyword location: A JSON Pointer from the root of a JSON Schema to the keyword that produced a result, such as /properties/shelf/properties/aisle/maximum. points from the schema’s root to the keyword that failed, such as
/properties/shelf/properties/aisle/maximum. The first tells you which value to fix, and the second tells you which
rule it broke, which matters when the same value is checked by several rules. Follow the validator through listing 4:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Start at the root. The validator starts with the whole listing and the whole schema. Both locations are the empty pointer, and the first keyword it acts on, type, asks for an object, which listing 4 is.
- One rule per member. properties holds one subschema per member. For every member the listing has, the validator steps into the value and into its subschema at the same time.
- format fails enum. The value at /format is "Paperback". The rule at /properties/format/enum lists only lower-case names, so the first error records both places.
- Into shelf. Next it steps into /shelf, with the subschema at /properties/shelf. Its type, object, holds, so the validator goes one level deeper.
- aisle fails maximum. /shelf/aisle holds 41, and the rule at /properties/shelf/properties/aisle/maximum allows at most 40. Each level added one token to each pointer.
- required holds. Back at the root, required names five members, and listing 4 has all of them, so nothing is added. A missing member would be reported at the object, "", with keyword /required.
- The report. Two errors, each with the place in the document and the place in the schema, exactly as the program printed them for listing 4.
Each step into a member added one token to the instance location and two to the keyword location, properties and the
member’s name. A missing member is the one case without a value to point at, so required reports at the object that
lacks it: listing 3’s missing stock was reported at "", the whole document, with keyword /required.
The specification describes these results as output with an instanceLocation and a keywordLocation for every error,
in four formats from a bare yes or no up to the full tree. Real validators use their own field names for the same two
places:
| Validator | Where the value is | Which rule failed |
|---|---|---|
| The lesson’s programs | at |
keyword |
| Ajv (JavaScript) | instancePath |
schemaPath and keyword |
| jsonschema (Python) | json_path and absolute_path |
absolute_schema_path and validator |
Ajv stops at the first error unless you pass allErrors: true, so a report from it may name one problem of several. To
use 2020-12 with Ajv, import the class from ajv/dist/2020, because its default class follows draft-07.
Key idea
Read an error by both of its locations: the instance location says which value to change, and the keyword location says which rule of the schema it broke.
Checkpoint
Check: Instance and keyword locations
A generated schema is a first draft: tighten it by hand
Writing a schema from nothing is slow, so tools infer one from sample documents. This program does what such a tool does: it records the type of every value it sees, and calls a member required when every sample has it. It reads three good listings, prints the draft, then checks a bad listing against the draft and against the schema of this lesson:
Python · first/infer.py
"""A first draft of a schema, inferred from three good listings, then the rules it cannot know."""
import json
from minischema import has_type, kind, report
SAMPLES = [
{"sku": "BK-101", "title": "Ponniyin Selvan, part 1", "format": "paperback",
"price_paise": 39900, "stock": 12, "shelf": {"branch": "Pune", "aisle": 7}},
{"sku": "BK-205", "title": "Malgudi Days", "format": "paperback", "price_paise": 24900, "stock": 4},
{"sku": "BK-310", "title": "Aranyak", "format": "hardback",
"price_paise": 54900, "stock": 0, "shelf": {"branch": "Kochi", "aisle": 12}},
]
def infer(values):
"""The type of every value seen; for objects, every name seen and the names seen every time."""
kinds = []
for value in values:
found = "integer" if has_type(value, "integer") else kind(value)
if found not in kinds:
kinds.append(found)
schema = {"type": kinds[0] if len(kinds) == 1 else kinds}
objects = [v for v in values if kind(v) == "object"]
if objects:
names = []
for obj in objects:
names += [name for name in obj if name not in names]
schema["properties"] = {n: infer([o[n] for o in objects if n in o]) for n in names}
schema["required"] = [n for n in names if all(n in o for o in objects)]
return schema
draft = infer(SAMPLES)
print(json.dumps(draft, indent=2))
TIGHTENED = json.loads("""
{
"type": "object",
"properties": {
"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"format": {"enum": ["paperback", "hardback", "ebook"]},
"price_paise": {"type": "integer", "exclusiveMinimum": 0},
"stock": {"type": "integer", "minimum": 0}
},
"required": ["sku", "title", "format", "price_paise", "stock"]
}
""")
bad = {"sku": "XYZ-9", "title": "", "format": "audiobook", "price_paise": 0, "stock": -3}
print()
report("the bad listing, inferred draft", draft, bad)
report("the bad listing, tightened by hand", TIGHTENED, bad) Output
{
"type": "object",
"properties": {
"sku": {
"type": "string"
},
"title": {
"type": "string"
},
"format": {
"type": "string"
},
"price_paise": {
"type": "integer"
},
"stock": {
"type": "integer"
},
"shelf": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"aisle": {
"type": "integer"
}
},
"required": [
"branch",
"aisle"
]
}
},
"required": [
"sku",
"title",
"format",
"price_paise",
"stock"
]
}
the bad listing, inferred draft: valid
the bad listing, tightened by hand: 5 errors
at /sku, keyword /properties/sku/pattern: "XYZ-9" does not match ^BK-[0-9]{3}$
at /title, keyword /properties/title/minLength: "" has 0 characters, fewer than 1
at /format, keyword /properties/format/enum: "audiobook" is not one of "paperback", "hardback", "ebook"
at /price_paise, keyword /properties/price_paise/exclusiveMinimum: 0 is not above 0
at /stock, keyword /properties/stock/minimum: -3 is below the minimum of 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 infer.py
JavaScript · first/infer.mjs
// A first draft of a schema, inferred from three good listings, then the rules it cannot know.
import { hasType, kind, report } from './minischema.mjs';
const SAMPLES = [
{ sku: 'BK-101', title: 'Ponniyin Selvan, part 1', format: 'paperback',
price_paise: 39900, stock: 12, shelf: { branch: 'Pune', aisle: 7 } },
{ sku: 'BK-205', title: 'Malgudi Days', format: 'paperback', price_paise: 24900, stock: 4 },
{ sku: 'BK-310', title: 'Aranyak', format: 'hardback',
price_paise: 54900, stock: 0, shelf: { branch: 'Kochi', aisle: 12 } },
];
// The type of every value seen; for objects, every name seen and the names seen every time.
function infer(values) {
const kinds = [];
for (const value of values) {
const found = hasType(value, 'integer') ? 'integer' : kind(value);
if (!kinds.includes(found)) kinds.push(found);
}
const schema = { type: kinds.length === 1 ? kinds[0] : kinds };
const objects = values.filter((v) => kind(v) === 'object');
if (objects.length) {
const names = [];
for (const obj of objects) names.push(...Object.keys(obj).filter((name) => !names.includes(name)));
schema.properties = Object.fromEntries(names.map((n) => [n, infer(objects.filter((o) => Object.hasOwn(o, n)).map((o) => o[n]))]));
schema.required = names.filter((n) => objects.every((o) => Object.hasOwn(o, n)));
}
return schema;
}
const draft = infer(SAMPLES);
console.log(JSON.stringify(draft, null, 2));
const TIGHTENED = JSON.parse(`
{
"type": "object",
"properties": {
"sku": {"type": "string", "pattern": "^BK-[0-9]{3}$"},
"title": {"type": "string", "minLength": 1, "maxLength": 80},
"format": {"enum": ["paperback", "hardback", "ebook"]},
"price_paise": {"type": "integer", "exclusiveMinimum": 0},
"stock": {"type": "integer", "minimum": 0}
},
"required": ["sku", "title", "format", "price_paise", "stock"]
}
`);
const bad = { sku: 'XYZ-9', title: '', format: 'audiobook', price_paise: 0, stock: -3 };
console.log();
report('the bad listing, inferred draft', draft, bad);
report('the bad listing, tightened by hand', TIGHTENED, bad); Output
{
"type": "object",
"properties": {
"sku": {
"type": "string"
},
"title": {
"type": "string"
},
"format": {
"type": "string"
},
"price_paise": {
"type": "integer"
},
"stock": {
"type": "integer"
},
"shelf": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"aisle": {
"type": "integer"
}
},
"required": [
"branch",
"aisle"
]
}
},
"required": [
"sku",
"title",
"format",
"price_paise",
"stock"
]
}
the bad listing, inferred draft: valid
the bad listing, tightened by hand: 5 errors
at /sku, keyword /properties/sku/pattern: "XYZ-9" does not match ^BK-[0-9]{3}$
at /title, keyword /properties/title/minLength: "" has 0 characters, fewer than 1
at /format, keyword /properties/format/enum: "audiobook" is not one of "paperback", "hardback", "ebook"
at /price_paise, keyword /properties/price_paise/exclusiveMinimum: 0 is not above 0
at /stock, keyword /properties/stock/minimum: -3 is below the minimum of 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node infer.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The draft describes the samples well and the rules poorly. It has types and a required list, and it was right that
shelf is optional, since one sample had none. But it cannot know that a format is one of three names, that a SKU looks
like BK- and three digits, or that a price is above 0, so it accepted the bad listing whole. The tightened schema
found five errors in the same listing.
Tighten a generated schema in the same order every time:
- Turn codes and states into
enum, and fixed-shape identifiers into anchoredpatterns with length limits. - Give every number its range, and every name that may not be empty
"minLength": 1. - Question
requiredboth ways: a member every sample had may still be optional, and one missing from a sample may be required in the data you do not have. - Check good samples and known bad ones against the result, as the program did, before any feed depends on it.
Checkpoint
Check: Tightening a generated schema
Practise: a schema for a seat booking
The exercise asks for a schema you write from a list of rules, the way you would from a ticket in a real project. The tests run 30 bookings through the lesson’s validator, so a rule you leave out, or one that is too strict, fails a test that says which booking it let through or refused. The PNR rules include the two cases the strings program showed:
Exercise · Easy · Python, JavaScript
A first schema for a seat booking
Write booking_schema() in booking.py (Python) and bookingSchema() in booking.mjs (JavaScript). Each returns a JSON Schema 2020-12, as a dict or an object, for one seat booking on a train:
pnr: a string of exactly 10 digits, each from 0 to 9.passengers: a whole number from 1 to 6.class: one of"SL","3A","2A"and"1A".berth: optional; one of"lower","middle","upper","side-lower","side-upper"and"any".fare_paise: the fare in paise, above 0 and in whole rupees, so a multiple of 100.pnr,passengers,classandfare_paiseare required. Other members, such as a note, may appear.
The tests check 30 bookings with the lesson's validator, which is at the top of each test file, so a schema passes when it says exactly what the rules say: no valid booking refused, no invalid one accepted.
Python · Starter code · booking.py
def booking_schema():
"""A JSON Schema 2020-12 for one seat booking (the rules are in the prompt)."""
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
} The sample tests · test_booking.py
import json
import re
from booking import booking_schema
# The lesson's validator (examples/first/minischema.py), reduced to the keywords a booking needs.
def kind(value):
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
return "array" if isinstance(value, list) else "object"
def has_type(value, name):
if name == "integer":
return kind(value) == "number" and (isinstance(value, int) or value.is_integer())
return kind(value) == name
def failures(schema, value):
"""The keywords that value fails in schema, nested ones included."""
out = []
for keyword, rule in schema.items():
if keyword == "type":
names = rule if isinstance(rule, list) else [rule]
bad = not any(has_type(value, name) for name in names)
elif keyword == "enum":
bad = not any(kind(value) == kind(o) and value == o for o in rule)
elif kind(value) == "string":
bad = (keyword == "minLength" and len(value) < rule) or (keyword == "maxLength" and len(value) > rule) or (
keyword == "pattern" and not re.search(rule, value))
elif kind(value) == "number":
bad = (keyword == "minimum" and value < rule) or (keyword == "maximum" and value > rule) or (
keyword == "exclusiveMinimum" and value <= rule) or (keyword == "exclusiveMaximum" and value >= rule) or (
keyword == "multipleOf" and not float(value / rule).is_integer())
elif kind(value) == "object" and keyword == "required":
bad = any(name not in value for name in rule)
elif kind(value) == "object" and keyword == "properties":
bad = False
for name, subschema in rule.items():
if name in value:
out += [f"{name}/{k}" for k in failures(subschema, value[name])]
else:
bad = False
if bad:
out.append(keyword)
return out
GOOD = '{"pnr": "4512398760", "passengers": 2, "class": "3A", "berth": "lower", "fare_paise": 124500}'
def booking(**changes):
"""The good booking with some members changed (None removes a member)."""
doc = json.loads(GOOD)
for name, value in changes.items():
if value is None:
del doc[name]
else:
doc[name] = value
return doc
def accepts(doc):
return failures(booking_schema(), doc) == []
def test_valid_bookings():
"""accepts the four valid bookings"""
assert accepts(booking())
assert accepts(booking(berth=None, pnr="0000000001", passengers=1, fare_paise=100))
assert accepts(booking(passengers=6, berth="any", fare_paise=1850000, note="two wheelchairs"))
assert accepts(json.loads('{"pnr": "9876543210", "passengers": 2.0, "class": "SL", "fare_paise": 45000}'))
def test_pnr():
"""refuses every PNR that is not exactly 10 digits 0-9"""
for pnr in ["451239876", "45123987601", "45123A8760", 4512398760, "4512398760\n", "४५१२३९८७६०", ""]:
assert not accepts(booking(pnr=pnr)), pnr
def test_passengers():
"""refuses 0, 7, 2.5, "2" and true passengers"""
for count in [0, 7, 2.5, "2", True]:
assert not accepts(booking(passengers=count)), count
def test_class_and_berth():
"""refuses classes and berths outside their lists"""
for value in ["3AC", "sl"]:
assert not accepts(booking(**{"class": value})), value
for value in ["window", "", 1]:
assert not accepts(booking(berth=value)), value
def test_fare():
"""refuses fares that are not whole rupees above 0, in paise"""
for fare in [0, -100, 124550, 1245.5, "124500"]:
assert not accepts(booking(fare_paise=fare)), fare
def test_required():
"""refuses a booking without pnr, passengers, class or fare_paise"""
for name in ["pnr", "passengers", "class", "fare_paise"]:
assert not accepts(booking(**{name: None})), name JavaScript · Starter code · booking.mjs
// A JSON Schema 2020-12 for one seat booking (the rules are in the prompt).
export function bookingSchema() {
return {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
};
} The sample tests · booking.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { bookingSchema } from './booking.mjs';
// The lesson's validator (examples/first/minischema.mjs), reduced to the keywords a booking needs.
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : typeof v);
const hasType = (v, name) => (name === 'integer' ? kind(v) === 'number' && Number.isInteger(v) : kind(v) === name);
// The keywords that value fails in schema, nested ones included.
function failures(schema, value) {
const out = [];
for (const [keyword, rule] of Object.entries(schema)) {
let bad = false;
if (keyword === 'type') bad = !(Array.isArray(rule) ? rule : [rule]).some((name) => hasType(value, name));
else if (keyword === 'enum') bad = !rule.some((o) => kind(o) === kind(value) && o === value);
else if (kind(value) === 'string') {
const n = [...value].length;
bad = (keyword === 'minLength' && n < rule) || (keyword === 'maxLength' && n > rule) || (keyword === 'pattern' && !new RegExp(rule, 'u').test(value));
} else if (kind(value) === 'number') {
bad = (keyword === 'minimum' && value < rule) || (keyword === 'maximum' && value > rule) || (keyword === 'exclusiveMinimum' && value <= rule) ||
(keyword === 'exclusiveMaximum' && value >= rule) || (keyword === 'multipleOf' && !Number.isInteger(value / rule));
} else if (kind(value) === 'object' && keyword === 'required') bad = rule.some((name) => !Object.hasOwn(value, name));
else if (kind(value) === 'object' && keyword === 'properties') {
for (const [name, subschema] of Object.entries(rule)) {
if (Object.hasOwn(value, name)) out.push(...failures(subschema, value[name]).map((k) => `${name}/${k}`));
}
}
if (bad) out.push(keyword);
}
return out;
}
const GOOD = '{"pnr": "4512398760", "passengers": 2, "class": "3A", "berth": "lower", "fare_paise": 124500}';
// The good booking with some members changed (null removes a member).
function booking(changes = {}) {
const doc = JSON.parse(GOOD);
for (const [name, value] of Object.entries(changes)) {
if (value === null) delete doc[name];
else doc[name] = value;
}
return doc;
}
const accepts = (doc) => failures(bookingSchema(), doc).length === 0;
test('accepts the four valid bookings', () => {
assert.ok(accepts(booking()));
assert.ok(accepts(booking({ berth: null, pnr: '0000000001', passengers: 1, fare_paise: 100 })));
assert.ok(accepts(booking({ passengers: 6, berth: 'any', fare_paise: 1850000, note: 'two wheelchairs' })));
assert.ok(accepts(JSON.parse('{"pnr": "9876543210", "passengers": 2.0, "class": "SL", "fare_paise": 45000}')));
});
test('refuses every PNR that is not exactly 10 digits 0-9', () => {
for (const pnr of ['451239876', '45123987601', '45123A8760', 4512398760, '4512398760\n', '४५१२३९८७६०', '']) {
assert.ok(!accepts(booking({ pnr })), String(pnr));
}
});
test('refuses 0, 7, 2.5, "2" and true passengers', () => {
for (const passengers of [0, 7, 2.5, '2', true]) assert.ok(!accepts(booking({ passengers })), String(passengers));
});
test('refuses classes and berths outside their lists', () => {
for (const value of ['3AC', 'sl']) assert.ok(!accepts(booking({ class: value })), value);
for (const value of ['window', '', 1]) assert.ok(!accepts(booking({ berth: value })), String(value));
});
test('refuses fares that are not whole rupees above 0, in paise', () => {
for (const fare of [0, -100, 124550, 1245.5, '124500']) assert.ok(!accepts(booking({ fare_paise: fare })), String(fare));
});
test('refuses a booking without pnr, passengers, class or fare_paise', () => {
for (const name of ['pnr', 'passengers', 'class', 'fare_paise']) assert.ok(!accepts(booking({ [name]: null })), name);
}); A hint
Start from the listing schema of the lesson: "type": "object", one subschema per member under properties, and the four names under required. For the PNR, remember the strings program: anchor the pattern with ^ and $, write [0-9] rather than \d, and bound the length as well, because a validator that uses Python's re lets $ match before a final newline.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on keywords, types, limits, error locations and generated schemas. The self-check starts with whatever you have missed most:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): does "type": "integer" accept the value 1.0 in JSON Schema 2020-12?
Yes. Since draft-06, an integer is any number with a zero fractional part, so 1.0, 1e2 and -0 are integers and 1.5 is
not. JSON has one kind of number, and the schema sees the value, not how it was written. Draft-04 called 1.0 a
non-integer, which is why old validators can disagree. A validator in Python must test float.is_integer(), because
json.loads turns 1.0 into a float, and must test for bool first, because True is an int in Python. The
specification still asks producers to write integers without a fraction.
Two harder questions follow, about a generated schema that lets bad data into a build and about a pattern that two services read differently. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Your team generated a JSON Schema from a month of API responses and checks every response against it in CI. A response with "status": "refundd" and a total of -500 paise passed. Why did the schema let it through, and how would you tighten it without failing good responses?
-
Mid to senior
A Python service and a Node.js service share a JSON Schema whose product code rule is the pattern ^[A-Z]{2}\d{4}$. The Python service accepts two codes that the Node.js service refuses. What could the two codes be, and how do you make the services agree?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does $schema tell a validator?
Which version of JSON Schema the keywords follow, such as https://json-schema.org/draft/2020-12/schema for 2020-12.
-
Does listing a member under properties make it compulsory?
No. properties checks a member only when it is present; only required makes it compulsory.
-
Does "type": "integer" accept 1.0 in 2020-12?
Yes: an integer is any number with a zero fractional part, since draft-06. 1.5 fails, and so does true, even though Python counts a bool as an int.
-
What do minLength and maxLength count?
Characters, not bytes and not the UTF-16 code units that JavaScript's length counts.
-
Does "pattern" have to match the whole string?
No. It searches anywhere in the string, in the ECMA-262 dialect, so anchor it with ^ and $ and write [0-9] rather than \d.
-
Are minimum and maximum inclusive?
Yes. exclusiveMinimum and exclusiveMaximum are the bounds that leave their value out.
-
What two locations does a JSON Schema error give?
The instance location, the value in the document to fix, and the keyword location, the rule of the schema it broke. A missing member is reported at the object that lacks it.
-
What does a schema generated from sample documents need before you trust it?
Rules: it holds the types and little else. Add enums, patterns, ranges and a checked required list, then test it on good and bad documents.
Key takeaways
- A schema is a JSON document whose keywords each check one thing;
$schemanames the version,propertiesapplies a subschema to each member that is there, and onlyrequiredmakes a member compulsory. typenames one of seven types, andintegermeans a number with a zero fractional part, so 2.0 counts andtruenever does, even in Python, where a boolean is anint.- Length limits count characters, not UTF-16 code units;
patternsearches anywhere in the string in the ECMA-262 dialect;minimumis inclusive andexclusiveMinimumis not; and amounts in integer paise keepmultipleOfexact. - Every error points twice, at the value in the document and at the keyword in the schema, and a missing member is reported at the object that lacks it.
- A schema inferred from samples has the types and little else: add enums, patterns, ranges and a checked
requiredlist, then test it on good and bad documents.
Terms in this lesson
- annotation
- Information a JSON Schema keyword attaches to a value without checking it, such as title, description, default or examples. A validator may collect annotations for an application to use.
- dialect
- The set of JSON Schema keywords a schema may use and what each means, named by the URI in $schema, such as https://json-schema.org/draft/2020-12/schema for version 2020-12.
- instance
- The JSON value a schema is applied to: the whole document at first, then each value inside it that a subschema is applied to.
- instance location
- A JSON Pointer to the value inside the document that a validation error or annotation is about, such as /shelf/aisle. The empty pointer names the whole document.
- keyword
- A member of a JSON Schema object with a meaning the specification defines, such as type, properties or required. Most keywords check the value they apply to; annotation keywords only describe it.
- keyword location
- A JSON Pointer from the root of a JSON Schema to the keyword that produced a result, such as /properties/shelf/properties/aisle/maximum.
- subschema
- A schema inside another JSON Schema, as the value of a keyword such as properties, items or allOf. Its keyword decides which value it is applied to: a member, an element or the same value again.
References
- JSON Schema specification (current version 2020-12) (JSON Schema)
- JSON Schema Core, draft 2020-12 (JSON Schema)
- JSON Schema Validation, draft 2020-12 (JSON Schema)
- JSON Schema draft-06 release notes (integer and exclusiveMinimum) (JSON Schema)
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format (IETF)
- Ajv: JSON Schema versions (draft 2020-12) (Ajv JSON schema validator)
- Ajv: API reference, validation errors (Ajv JSON schema validator)
- Ajv: options (allErrors) (Ajv JSON schema validator)
- jsonschema: handling validation errors (python-jsonschema project)
- re: regular expression operations ($ and \d) (Python Software Foundation)
- Built-in types: numeric types (booleans are a subtype of integers) (Python Software Foundation)
- String: length (UTF-16 code units) (Mozilla)
- Regular expressions: character class escape (\d) (Mozilla)
- Regular expressions: input boundary assertion ($) (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress