Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A validator for the keywords of a first schema

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:

Four listings against the listing schema

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

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:

What integer and number accept, and multipleOf

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

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

  1. Question 1 of 2 Listing 3 had no stock member. Which keyword of the listing schema reported it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: required, at the whole document

    properties and required split the work: properties says what each member must look like when it is there, and required says which members must be there.

  2. Question 2 of 2 Which of these JSON values does "type": "integer" accept?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2.0

    JSON has one kind of number; integer is a check of its value. 2.0, 1e2 and -0 pass; 2.5, "2" and true do not.

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:

How length and pattern work in each language

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

"📚 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

  1. Question 1 of 2 A schema says "pattern": "[0-9]{3}" for a SKU. Which value does it accept that the bookshop never wanted?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "old code BK-1011 (2 copies)"

    The specification says patterns are not implicitly anchored. ^BK-[0-9]{3}$ describes the whole value.

  2. Question 2 of 2 Which value does {"type": "integer", "exclusiveMinimum": 0} refuse?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0

    minimum is inclusive and exclusiveMinimum is not. Use minimum 0 for a stock that may be zero and exclusiveMinimum 0 for a price that may not.

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:

Interactive · stage by stage Checking listing 4, keyword by keyword Step through the validator as it walks the schema and the listing together. Watch both locations grow by one token at each level.
Checking listing 4, keyword by keyword, step 7 of 7. Showing The schema root (type object: holds), properties, format (error at /format), shelf (type object: holds), shelf.aisle (error at /shelf/aisle), required (all five present), The report (2 errors). In focus: The report.The schema roottype object: holdspropertiesone subschema per memberformaterror at /formatshelftype object: holdsshelf.aisleerror at /shelf/aislerequiredall five presentThe report2 errors

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. Into shelf. Next it steps into /shelf, with the subschema at /properties/shelf. Its type, object, holds, so the validator goes one level deeper.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 A listing has no title. What instance location does the error carry?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "", the listing that lacks the member

    The instance location names the value to fix. For a missing member that is the object, and the keyword location /required says which rule.

  2. Question 2 of 2 An error has instance location /shelf/aisle. Under the listing schema, what is its keyword location when the aisle is 0?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /properties/shelf/properties/aisle/minimum

    The keyword location follows the schema's own structure, so it is longer than the instance location: two tokens per member, then the keyword.

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:

A schema inferred from three listings, then tightened

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

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:

  1. Turn codes and states into enum, and fixed-shape identifiers into anchored patterns with length limits.
  2. Give every number its range, and every name that may not be empty "minLength": 1.
  3. Question required both 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.
  4. Check good samples and known bad ones against the result, as the program did, before any feed depends on it.
JSON Schema Generator Infer a 2020-12 or draft-07 schema from your own samples, with enums for small sets of repeated values, as a first draft to tighten. JSON Schema Validator Check a document against a schema and see every error with its path, its line and the keyword that failed.

Checkpoint

Check: Tightening a generated schema

  1. Question 1 of 2 Why did the inferred draft leave shelf out of required?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One of the three samples had no shelf

    Inference turns what the samples happened to contain into rules, which is why every rule it writes needs a second look.

  2. Question 2 of 2 The inferred draft has "format": {"type": "string"}. What should replace it, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: {"enum": ["paperback", "hardback", "ebook"]}, because only three formats exist

    A code or a state with a fixed list of values is an enum. Inference sees only the values in the samples, so it cannot know the whole list.

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, class and fare_paise are 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.

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.

  1. Question 1 of 13 Is {} valid against {"type": "object", "properties": {"stock": {"type": "integer"}}}?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: Yes, because properties checks only the members that are there

    properties and required do different jobs. A schema with properties alone accepts an object with none of them.

  2. Question 2 of 13 What does "$schema": "https://json-schema.org/draft/2020-12/schema" do in a schema?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: It names the version of JSON Schema whose keywords the schema follows

    Meanings changed between versions, for example exclusiveMinimum and integer, so stating the version makes a schema mean the same in every validator that reads it.

  3. Question 3 of 13 A teammate adds "default": 0 to the stock subschema, so that listings without stock "become 0". The schema also requires stock. What happens to a listing without stock?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: It still fails required; default is an annotation and changes nothing

    An application may read default and fill values in itself, but that is its choice. To the validator, default is information, like title.

  4. Question 4 of 13 A member has "type": ["string", "null"]. Which value fails?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: 0

    A list of types passes when the value has any one of them. The usual use is a value that may be missing as null.

  5. Question 5 of 13 A validator written in Python checks integer with isinstance(value, int). Which listing does it accept wrongly?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: One with "stock": true

    That check is wrong twice: it accepts true and refuses 2.0. The lesson's kind() tests bool first and has_type() accepts floats with is_integer().

  6. Question 6 of 13 Is the JSON number 1e2 an integer for JSON Schema 2020-12?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Yes, it is 100, a number with a zero fractional part

    2020-12 defines an integer by its value. 1e2, 100 and 100.0 are the same integer.

  7. Question 7 of 13 Is the string "📚📚📚" valid against {"maxLength": 3}?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Yes, it is three characters

    A validator in JavaScript must count with [...value].length, or it refuses strings like the gift-set title.

  8. Question 8 of 13 A Python service accepts "4512398760\n" against "pattern": "^[0-9]{10}$", and a Node.js service refuses it. What change to the schema makes both refuse it?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Add "minLength": 10 and "maxLength": 10

    In Python's re, $ also matches before a final newline. A length bound is read the same way everywhere, so it closes the gap without depending on the regular-expression engine.

  9. Question 9 of 13 Why did {"multipleOf": 0.01} refuse 19.99 in the program?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: In binary floating point, 19.99 / 0.01 is 1998.9999999999998, not an integer

    Amounts in integer paise make the rule exact: "multipleOf": 100 on price_paise accepted 39900 and refused 24950.

  10. Question 10 of 13 Which part of an error tells you which rule of the schema was broken?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The keyword location

    The instance location says where to fix the data, the keyword location says which rule to read.

  11. Question 11 of 13 For listing 4, Ajv with default options reports one error and the lesson's program two. Why?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Ajv returns after the first error unless allErrors is true

    A report from a validator that stops at the first error can hide the next problem. Turn on its option to collect them all when people will read the report.

  12. Question 12 of 13 Which rule can a schema inferred from samples not know?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: The full list of values a field may take

    Types and presence come from the samples; allowed values, shapes and ranges come from the people who know the data, and are added by hand.

  13. Question 13 of 13 All three samples happened to have a gift_note, so the inferred schema requires it. Next week a listing without one fails. What do you change?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Remove gift_note from required, and keep its subschema under properties

    Question required both ways: present in every sample is not the same as required in the data.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

  2. Does listing a member under properties make it compulsory?

    No. properties checks a member only when it is present; only required makes it compulsory.

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

  4. What do minLength and maxLength count?

    Characters, not bytes and not the UTF-16 code units that JavaScript's length counts.

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

  6. Are minimum and maximum inclusive?

    Yes. exclusiveMinimum and exclusiveMaximum are the bounds that leave their value out.

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

  8. 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; $schema names the version, properties applies a subschema to each member that is there, and only required makes a member compulsory.
  • type names one of seven types, and integer means a number with a zero fractional part, so 2.0 counts and true never does, even in Python, where a boolean is an int.
  • Length limits count characters, not UTF-16 code units; pattern searches anywhere in the string in the ECMA-262 dialect; minimum is inclusive and exclusiveMinimum is not; and amounts in integer paise keep multipleOf exact.
  • 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 required list, 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.