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

JSON Schema formats, vocabularies and validators

Why format only labels a value by default in 2020-12, how to make it check, what $vocabulary says, how to catch misspelt keywords and pick a validator.

  • Advanced
  • 15 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: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain why format only annotates by default in 2020-12, and make a validator assert it
  • Read a meta-schema's $vocabulary to see which keywords a dialect supports
  • Catch misspelt keywords that the specification tells validators to ignore
  • Compare validators with the official test suite before choosing one

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain why format only annotates by default in 2020-12, and make a validator assert it
  • Read a meta-schema's $vocabulary to see which keywords a dialect supports
  • Catch misspelt keywords that the specification tells validators to ignore
  • Compare validators with the official test suite before choosing one

Before you start

On this page

The bookshop’s order schema says "format": "email", and an order with the address "not-an-email" passed it, so the confirmation went nowhere. Nothing was broken: in JSON Schema 2020-12, format only labels a value unless a validator is told to check it, one of 3 defaults in this lesson that surprise people.

You will see what format does by default and make it assert, read the $vocabulary of a meta-schema to know which keywords a dialect has, catch misspelt keywords that a validator silently ignores, and compare validators with the official test suite before trusting one.

format is an annotation by default, and asserting it is a choice

2020-12 has two vocabularies for one keyword. Under format-annotation, which the standard meta-schema requires, format is collected as an 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. More in “Your first JSON Schema 2020-12”: the validator reports that /email is meant to be an email and checks nothing. Under format-assertion, which is optional to implement, a validator must check every format it meets and fail on a format it does not know. A validator may also offer an option to check formats in the standard dialect, and the specification says that option must be off by default. The program checks four formats on their own, then validates one order both ways:

Keywords by vocabulary, format checks and a keyword finder

Python · vocab/minischema.py

"""The keywords of JSON Schema 2020-12 by vocabulary, format checked as an annotation or as an assertion, a check
that a validator supports a dialect's vocabularies, and a finder for keywords that no vocabulary defines."""

import json
import re

V = "https://json-schema.org/draft/2020-12/vocab/"
KEYWORDS = {  # the vocabularies of the 2020-12 dialect and the keywords each one defines
    V + "core": "$id $schema $ref $anchor $dynamicRef $dynamicAnchor $vocabulary $comment $defs",
    V + "applicator": "allOf anyOf oneOf not if then else dependentSchemas prefixItems items contains "
                      "properties patternProperties additionalProperties propertyNames",
    V + "unevaluated": "unevaluatedItems unevaluatedProperties",
    V + "validation": "type enum const multipleOf maximum exclusiveMaximum minimum exclusiveMinimum maxLength "
                      "minLength pattern maxItems minItems uniqueItems maxContains minContains maxProperties "
                      "minProperties required dependentRequired",
    V + "meta-data": "title description default deprecated readOnly writeOnly examples",
    V + "format-annotation": "format",
    V + "content": "contentEncoding contentMediaType contentSchema",
}
KNOWN = {k for words in KEYWORDS.values() for k in words.split()}
MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]


def date_time(text):
    """RFC 3339 section 5.6 date-time, with the day checked against its month and year."""
    m = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?"
                     r"([Zz]|[+-][0-9]{2}:[0-9]{2})", text)
    if not m:
        return False
    year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
    leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
    days = 29 if month == 2 and leap else MONTH_DAYS[month - 1] if 1 <= month <= 12 else 0
    return 1 <= day <= days and hour <= 23 and minute <= 59 and second <= 60


OCTET = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])"  # 0 to 255, without leading zeros
ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+"
LABEL = "[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?"
HEX = "[0-9a-fA-F]"
FORMATS = {  # each is a full match; email is RFC 5321's Mailbox without quoted local parts and address literals
    "email": lambda s: re.fullmatch(rf"{ATEXT}(\.{ATEXT})*@{LABEL}(\.{LABEL})*", s) is not None,
    "date-time": date_time,
    "ipv4": lambda s: re.fullmatch(rf"{OCTET}(\.{OCTET}){{3}}", s) is not None,
    "uuid": lambda s: re.fullmatch(rf"{HEX}{{8}}(-{HEX}{{4}}){{3}}-{HEX}{{12}}", s) is not None,
}


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 step(pointer, token):
    return pointer + "/" + str(token).replace("~", "~0").replace("/", "~1")


def validate(schema, value, assert_formats=False, at=""):
    """(errors, annotations). format is collected as an annotation, and checked only when assert_formats is on."""
    errors, notes = [], []
    for keyword, rule in schema.items():
        if keyword == "type" and not has_type(value, rule):
            errors.append((at, f"expected {rule}, got {kind(value)}"))
        elif keyword == "format" and kind(value) == "string":
            notes.append((at, f"format {rule}"))
            known = rule in FORMATS
            if assert_formats and (not known or not FORMATS[rule](value)):
                errors.append((at, f"{json.dumps(value)} is not a valid {rule}" if known else f"unknown format {rule}"))
        elif keyword == "required" and kind(value) == "object":
            errors += [(at, f"missing required property {json.dumps(n)}") for n in rule if n not in value]
        elif keyword == "minLength" and kind(value) == "string" and len(value) < rule:
            errors.append((at, f"{json.dumps(value)} is shorter than {rule}"))
        elif keyword == "minimum" and kind(value) == "number" and value < rule:
            errors.append((at, f"{value} is below the minimum of {rule}"))
        elif keyword == "additionalProperties" and rule is False and kind(value) == "object":
            errors += [(step(at, n), f"property {json.dumps(n)} is not allowed") for n in value if n not in schema.get("properties", {})]
        elif keyword == "properties" and kind(value) == "object":
            for name, sub in rule.items():
                if name in value:
                    found = validate(sub, value[name], assert_formats, step(at, name))
                    errors, notes = errors + found[0], notes + found[1]
    return errors, notes


def can_process(vocabularies, supported):
    """None when a validator that supports these vocabularies may process the dialect, else the reason it must refuse."""
    missing = [uri for uri, required in vocabularies.items() if required and uri not in supported]
    return f"it requires {', '.join(missing)}" if missing else None


def distance(a, b):
    """Levenshtein distance: the fewest single-character edits that turn a into b."""
    row = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        prev, row[0] = row[0], i
        for j, cb in enumerate(b, 1):
            prev, row[j] = row[j], min(row[j] + 1, row[j - 1] + 1, prev + (ca != cb))
    return row[-1]


MAPS = ("properties", "patternProperties", "$defs", "dependentSchemas")
LISTS = ("allOf", "anyOf", "oneOf", "prefixItems")
SINGLE = ("items", "contains", "not", "if", "then", "else", "additionalProperties", "propertyNames",
          "unevaluatedItems", "unevaluatedProperties", "contentSchema")


def unknown_keywords(schema, where=""):
    """(location, the closest keyword or None) for every name in a schema that no vocabulary defines."""
    found = []
    if not isinstance(schema, dict):
        return found
    for name, value in schema.items():
        here = step(where, name)
        if name not in KNOWN and not name.startswith("x-"):
            close = min(sorted(KNOWN), key=lambda k: distance(name, k))
            found.append((here, close if distance(name, close) <= 2 else None))
        elif name in MAPS:
            for key, sub in value.items():
                found += unknown_keywords(sub, step(here, key))
        elif name in LISTS:
            for i, sub in enumerate(value):
                found += unknown_keywords(sub, step(here, i))
        elif name in SINGLE:
            found += unknown_keywords(value, here)
    return found

JavaScript · vocab/minischema.mjs

// The keywords of JSON Schema 2020-12 by vocabulary, format checked as an annotation or as an assertion, a check
// that a validator supports a dialect's vocabularies, and a finder for keywords that no vocabulary defines.

const V = 'https://json-schema.org/draft/2020-12/vocab/';
export const KEYWORDS = { // the vocabularies of the 2020-12 dialect and the keywords each one defines
  [`${V}core`]: '$id $schema $ref $anchor $dynamicRef $dynamicAnchor $vocabulary $comment $defs',
  [`${V}applicator`]: 'allOf anyOf oneOf not if then else dependentSchemas prefixItems items contains ' +
    'properties patternProperties additionalProperties propertyNames',
  [`${V}unevaluated`]: 'unevaluatedItems unevaluatedProperties',
  [`${V}validation`]: 'type enum const multipleOf maximum exclusiveMaximum minimum exclusiveMinimum maxLength ' +
    'minLength pattern maxItems minItems uniqueItems maxContains minContains maxProperties ' +
    'minProperties required dependentRequired',
  [`${V}meta-data`]: 'title description default deprecated readOnly writeOnly examples',
  [`${V}format-annotation`]: 'format',
  [`${V}content`]: 'contentEncoding contentMediaType contentSchema',
};
export const KNOWN = new Set(Object.values(KEYWORDS).flatMap((words) => words.split(' ')));
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

// RFC 3339 section 5.6 date-time, with the day checked against its month and year.
function dateTime(text) {
  const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$/.exec(text);
  if (!m) return false;
  const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
  const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
  const days = month === 2 && leap ? 29 : month >= 1 && month <= 12 ? MONTH_DAYS[month - 1] : 0;
  return day >= 1 && day <= days && hour <= 23 && minute <= 59 && second <= 60;
}

const OCTET = '(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'; // 0 to 255, without leading zeros
const ATEXT = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+";
const LABEL = '[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?';
const HEX = '[0-9a-fA-F]';
const full = (pattern) => (s) => new RegExp(`^(?:${pattern})$`).test(s);
export const FORMATS = { // each is a full match; email is RFC 5321's Mailbox without quoted local parts and address literals
  email: full(`${ATEXT}(\\.${ATEXT})*@${LABEL}(\\.${LABEL})*`),
  'date-time': dateTime,
  ipv4: full(`${OCTET}(\\.${OCTET}){3}`),
  uuid: full(`${HEX}{8}(-${HEX}{4}){3}-${HEX}{12}`),
};

export function kind(value) {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  return typeof value === 'object' ? 'object' : typeof value;
}

const hasType = (value, name) => (name === 'integer' ? kind(value) === 'number' && Number.isInteger(value) : kind(value) === name);
export const step = (pointer, token) => `${pointer}/${String(token).replaceAll('~', '~0').replaceAll('/', '~1')}`;

// [errors, annotations]. format is collected as an annotation, and checked only when assertFormats is on.
export function validate(schema, value, assertFormats = false, at = '') {
  let errors = [];
  let notes = [];
  for (const [keyword, rule] of Object.entries(schema)) {
    if (keyword === 'type' && !hasType(value, rule)) {
      errors.push([at, `expected ${rule}, got ${kind(value)}`]);
    } else if (keyword === 'format' && kind(value) === 'string') {
      notes.push([at, `format ${rule}`]);
      const known = Object.hasOwn(FORMATS, rule);
      if (assertFormats && (!known || !FORMATS[rule](value))) errors.push([at, known ? `${JSON.stringify(value)} is not a valid ${rule}` : `unknown format ${rule}`]);
    } else if (keyword === 'required' && kind(value) === 'object') {
      errors.push(...rule.filter((n) => !Object.hasOwn(value, n)).map((n) => [at, `missing required property ${JSON.stringify(n)}`]));
    } else if (keyword === 'minLength' && kind(value) === 'string' && [...value].length < rule) {
      errors.push([at, `${JSON.stringify(value)} is shorter than ${rule}`]);
    } else if (keyword === 'minimum' && kind(value) === 'number' && value < rule) {
      errors.push([at, `${value} is below the minimum of ${rule}`]);
    } else if (keyword === 'additionalProperties' && rule === false && kind(value) === 'object') {
      const named = schema.properties ?? {};
      errors.push(...Object.keys(value).filter((n) => !Object.hasOwn(named, n)).map((n) => [step(at, n), `property ${JSON.stringify(n)} is not allowed`]));
    } else if (keyword === 'properties' && kind(value) === 'object') {
      for (const [name, sub] of Object.entries(rule)) {
        if (Object.hasOwn(value, name)) {
          const [e, n] = validate(sub, value[name], assertFormats, step(at, name));
          errors = [...errors, ...e];
          notes = [...notes, ...n];
        }
      }
    }
  }
  return [errors, notes];
}

// null when a validator that supports these vocabularies may process the dialect, else the reason it must refuse.
export function canProcess(vocabularies, supported) {
  const missing = Object.entries(vocabularies).filter(([uri, required]) => required && !supported.has(uri)).map(([uri]) => uri);
  return missing.length ? `it requires ${missing.join(', ')}` : null;
}

// Levenshtein distance: the fewest single-character edits that turn a into b.
export function distance(a, b) {
  const row = Array.from({ length: b.length + 1 }, (_, j) => j);
  for (let i = 1; i <= a.length; i++) {
    let prev = row[0];
    row[0] = i;
    for (let j = 1; j <= b.length; j++) {
      const old = row[j];
      row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] !== b[j - 1] ? 1 : 0));
      prev = old;
    }
  }
  return row[b.length];
}

const MAPS = ['properties', 'patternProperties', '$defs', 'dependentSchemas'];
const LISTS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
const SINGLE = ['items', 'contains', 'not', 'if', 'then', 'else', 'additionalProperties', 'propertyNames',
  'unevaluatedItems', 'unevaluatedProperties', 'contentSchema'];
const SORTED = [...KNOWN].sort();

// [location, the closest keyword or null] for every name in a schema that no vocabulary defines.
export function unknownKeywords(schema, where = '') {
  const found = [];
  if (kind(schema) !== 'object') return found;
  for (const [name, value] of Object.entries(schema)) {
    const here = step(where, name);
    if (!KNOWN.has(name) && !name.startsWith('x-')) {
      let close = SORTED[0];
      for (const k of SORTED) if (distance(name, k) < distance(name, close)) close = k;
      found.push([here, distance(name, close) <= 2 ? close : null]);
    } else if (MAPS.includes(name)) {
      for (const [key, sub] of Object.entries(value)) found.push(...unknownKeywords(sub, step(here, key)));
    } else if (LISTS.includes(name)) {
      value.forEach((sub, i) => found.push(...unknownKeywords(sub, step(here, i))));
    } else if (SINGLE.includes(name)) {
      found.push(...unknownKeywords(value, here));
    }
  }
  return found;
}
Four formats, then one order with format as an annotation and asserted

Python · vocab/formats.py

"""Four formats checked on their own, then one schema with format as an annotation and with formats asserted."""

import json

from minischema import FORMATS, validate

SAMPLES = {
    "email": ["asha@example.com", "not-an-email"],
    "date-time": ["2025-03-14T10:30:00+05:30", "2025-03-14 10:30", "2025-02-29T10:30:00Z"],
    "ipv4": ["192.168.1.30", "192.168.1.300"],
    "uuid": ["a3bb189e-8bf9-3888-9912-ace4e6543002", "a3bb189e8bf938889912ace4e6543002"],
}
for name, values in SAMPLES.items():
    for value in values:
        print(f"{name} {json.dumps(value)}: {'fits' if FORMATS[name](value) else 'does not fit'}")

SCHEMA = json.loads("""{"type": "object", "required": ["email"], "properties": {
  "email": {"type": "string", "format": "email"},
  "placed": {"type": "string", "format": "date-time"},
  "phone": {"type": "string", "format": "phone"}}}""")
order = {"email": "not-an-email", "placed": "2025-03-14 10:30", "phone": "98450 12345"}
print()
for asserted in (False, True):
    errors, notes = validate(SCHEMA, order, assert_formats=asserted)
    print(f"formats {'asserted' if asserted else 'as annotations'}: {'valid' if not errors else f'{len(errors)} errors'}")
    for at, message in errors:
        print(f"  error at {at}: {message}")
    if not asserted:
        for at, message in notes:
            print(f"  annotation at {at}: {message}")

Output

email "asha@example.com": fits
email "not-an-email": does not fit
date-time "2025-03-14T10:30:00+05:30": fits
date-time "2025-03-14 10:30": does not fit
date-time "2025-02-29T10:30:00Z": does not fit
ipv4 "192.168.1.30": fits
ipv4 "192.168.1.300": does not fit
uuid "a3bb189e-8bf9-3888-9912-ace4e6543002": fits
uuid "a3bb189e8bf938889912ace4e6543002": does not fit

formats as annotations: valid
  annotation at /email: format email
  annotation at /placed: format date-time
  annotation at /phone: format phone
formats asserted: 3 errors
  error at /email: "not-an-email" is not a valid email
  error at /placed: "2025-03-14 10:30" is not a valid date-time
  error at /phone: unknown format phone

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 formats.py

JavaScript · vocab/formats.mjs

// Four formats checked on their own, then one schema with format as an annotation and with formats asserted.
import { FORMATS, validate } from './minischema.mjs';

const SAMPLES = {
  email: ['asha@example.com', 'not-an-email'],
  'date-time': ['2025-03-14T10:30:00+05:30', '2025-03-14 10:30', '2025-02-29T10:30:00Z'],
  ipv4: ['192.168.1.30', '192.168.1.300'],
  uuid: ['a3bb189e-8bf9-3888-9912-ace4e6543002', 'a3bb189e8bf938889912ace4e6543002'],
};
for (const [name, values] of Object.entries(SAMPLES)) {
  for (const value of values) console.log(`${name} ${JSON.stringify(value)}: ${FORMATS[name](value) ? 'fits' : 'does not fit'}`);
}

const SCHEMA = JSON.parse(`{"type": "object", "required": ["email"], "properties": {
  "email": {"type": "string", "format": "email"},
  "placed": {"type": "string", "format": "date-time"},
  "phone": {"type": "string", "format": "phone"}}}`);
const order = { email: 'not-an-email', placed: '2025-03-14 10:30', phone: '98450 12345' };
console.log();
for (const asserted of [false, true]) {
  const [errors, notes] = validate(SCHEMA, order, asserted);
  console.log(`formats ${asserted ? 'asserted' : 'as annotations'}: ${errors.length ? `${errors.length} errors` : 'valid'}`);
  for (const [at, message] of errors) console.log(`  error at ${at}: ${message}`);
  if (!asserted) for (const [at, message] of notes) console.log(`  annotation at ${at}: ${message}`);
}

Output

email "asha@example.com": fits
email "not-an-email": does not fit
date-time "2025-03-14T10:30:00+05:30": fits
date-time "2025-03-14 10:30": does not fit
date-time "2025-02-29T10:30:00Z": does not fit
ipv4 "192.168.1.30": fits
ipv4 "192.168.1.300": does not fit
uuid "a3bb189e-8bf9-3888-9912-ace4e6543002": fits
uuid "a3bb189e8bf938889912ace4e6543002": does not fit

formats as annotations: valid
  annotation at /email: format email
  annotation at /placed: format date-time
  annotation at /phone: format phone
formats asserted: 3 errors
  error at /email: "not-an-email" is not a valid email
  error at /placed: "2025-03-14 10:30" is not a valid date-time
  error at /phone: unknown format phone

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node formats.mjs

Each check follows the format’s standard: date-time is RFC 3339’s production, so a space instead of T fails, and so does a leap day in a year that has none; email is RFC 5321’s mailbox, which this small check implements without quoted local parts; ipv4 is four decimal numbers up to 255; uuid is RFC 4122’s five groups of hex digits. As annotations, the order was valid and the report listed three labels. Asserted, it got three errors, and phone, which the specification does not define, failed as an unknown format.

Validators make this choice in different places, and their documentation says how:

Validator Checks formats when And note
Ajv (JavaScript) the ajv-formats plugin adds them strict mode refuses an unknown format when the schema is compiled
jsonschema (Python) you pass a format_checker some formats need extra packages, and without them the check passes

Trusting a format check you never saw fail

Wrong: adding "format": "date-time" and assuming bad timestamps are refused. Right: put one bad value in your tests. Python’s jsonschema documents that a format whose package is missing passes without an error, and a validator in annotation mode refuses nothing, so only a failing test proves the check runs.

contentEncoding, contentMediaType and contentSchema, for strings that hold encoded data, are annotations in the same way: the specification says validators must not decode and check such strings by default. Decide where your formats are checked from your own case:

Interactive · answer and compare Where should your formats be checked? Answer two questions about your data. Each answer rules choices out or adds a caveat, with the reason.
Must a value with a bad format stop the request?
Do services in more than one language validate the same data?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three places to check format, against three needs
Where format is checked Bad values fail Same answer everywhere Errors name the member
Nowhere: the default Checks: nothing; format is a label Poor "not-an-email" passed as valid Good the default every 2020-12 validator follows Mixed annotations name /email, but nothing fails
In the validator, asserted Checks: the formats it knows Good 3 errors, one per member Mixed each validator checks formats its own way Good each error carries its member, such as /email
In your application code Checks: what you write Good you decide what an email is Good one check, wherever the data goes Mixed you attach the location yourself
How the answers decide
  • “Must a value with a bad format stop the request?” Yes, refuse it: rules out Nowhere: the default. With format as an annotation, the order with "not-an-email" and a timestamp without an offset was valid.
  • “Must a value with a bad format stop the request?” Yes, refuse it and “Do services in more than one language validate the same data?” Yes, several validators: adds a caveat to In the validator, asserted. Asserting formats is optional, and validators differ: Python's jsonschema checks some formats only when extra packages are installed. Test the same values in each.

$vocabulary says which keywords a dialect has

The meaning of every keyword comes from a vocabulary, a named set of keywords. A meta-schema: A JSON Schema that describes schemas: the document a $schema URI names. Its $vocabulary lists the vocabularies of its dialect, and it checks that a schema uses their keywords correctly., the document a $schema URI names, lists the vocabularies its dialect uses in $vocabulary, each with true (required) or false (optional). The 2020-12 meta-schema lists seven, all required. The program counts what each brings:

The seven vocabularies of 2020-12, and three dialect decisions

Python · vocab/vocab.py

"""The vocabularies of the 2020-12 dialect, and whether a validator may process a dialect that needs another one."""

from minischema import KEYWORDS, V, can_process

# The $vocabulary of the 2020-12 meta-schema, https://json-schema.org/draft/2020-12/schema
META = {V + name: True for name in ["core", "applicator", "unevaluated", "validation", "meta-data",
                                    "format-annotation", "content"]}
for uri, required in META.items():
    count = len(KEYWORDS[uri].split())
    print(f"{uri.removeprefix(V):<18} {'required' if required else 'optional'}, {count} keyword{'s' * (count > 1)}")
print("in all:", sum(len(KEYWORDS[uri].split()) for uri in META), "keywords")

SUPPORTED = set(META)  # a validator that supports exactly the standard dialect
BOOKSHOP = {V + name: True for name in ["core", "applicator", "validation", "meta-data", "format-assertion"]}
print()
for label, vocabularies, supported in [
    ("the 2020-12 dialect", META, SUPPORTED),
    ("the bookshop's dialect", BOOKSHOP, SUPPORTED),
    ("the bookshop's dialect, format-assertion supported", BOOKSHOP, SUPPORTED | {V + "format-assertion"}),
]:
    refusal = can_process(vocabularies, supported)
    print(f"{label}: {'processed' if refusal is None else 'refused, because ' + refusal}")

Output

core               required, 9 keywords
applicator         required, 15 keywords
unevaluated        required, 2 keywords
validation         required, 20 keywords
meta-data          required, 7 keywords
format-annotation  required, 1 keyword
content            required, 3 keywords
in all: 57 keywords

the 2020-12 dialect: processed
the bookshop's dialect: refused, because it requires https://json-schema.org/draft/2020-12/vocab/format-assertion
the bookshop's dialect, format-assertion supported: processed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vocab.py

JavaScript · vocab/vocab.mjs

// The vocabularies of the 2020-12 dialect, and whether a validator may process a dialect that needs another one.
import { KEYWORDS, canProcess } from './minischema.mjs';

const V = 'https://json-schema.org/draft/2020-12/vocab/';
// The $vocabulary of the 2020-12 meta-schema, https://json-schema.org/draft/2020-12/schema
const META = Object.fromEntries(['core', 'applicator', 'unevaluated', 'validation', 'meta-data', 'format-annotation', 'content'].map((n) => [V + n, true]));
for (const [uri, required] of Object.entries(META)) {
  const count = KEYWORDS[uri].split(' ').length;
  console.log(`${uri.slice(V.length).padEnd(18)} ${required ? 'required' : 'optional'}, ${count} keyword${count > 1 ? 's' : ''}`);
}
console.log('in all:', Object.keys(META).reduce((n, uri) => n + KEYWORDS[uri].split(' ').length, 0), 'keywords');

const SUPPORTED = new Set(Object.keys(META)); // a validator that supports exactly the standard dialect
const BOOKSHOP = Object.fromEntries(['core', 'applicator', 'validation', 'meta-data', 'format-assertion'].map((n) => [V + n, true]));
console.log();
for (const [label, vocabularies, supported] of [
  ['the 2020-12 dialect', META, SUPPORTED],
  ["the bookshop's dialect", BOOKSHOP, SUPPORTED],
  ["the bookshop's dialect, format-assertion supported", BOOKSHOP, new Set([...SUPPORTED, `${V}format-assertion`])],
]) {
  const refusal = canProcess(vocabularies, supported);
  console.log(`${label}: ${refusal === null ? 'processed' : `refused, because ${refusal}`}`);
}

Output

core               required, 9 keywords
applicator         required, 15 keywords
unevaluated        required, 2 keywords
validation         required, 20 keywords
meta-data          required, 7 keywords
format-annotation  required, 1 keyword
content            required, 3 keywords
in all: 57 keywords

the 2020-12 dialect: processed
the bookshop's dialect: refused, because it requires https://json-schema.org/draft/2020-12/vocab/format-assertion
the bookshop's dialect, format-assertion supported: processed

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node vocab.mjs

Seven vocabularies define the 57 keywords of 2020-12, from core’s $id and $ref to content’s three. format-assertion is not among them: the standard dialect has format-annotation instead. A dialect of your own, written as a meta-schema of your own, can require format-assertion, and then the rule of the core specification decides: a validator that does not support a required vocabulary must refuse to process the schema, rather than quietly ignoring the keywords it does not know. Step through the three decisions the program printed:

Interactive · stage by stage Can this validator process the schema? Step through two dialects and one validator. Each required vocabulary must be one the validator supports.
Can this validator process the schema?, step 6 of 6. Showing The schema's $schema (the bookshop's dialect), The meta-schema, Its $vocabulary (requires format-assertion), Required and supported? (all required supported), Process the schema (processed, formats checked). In focus: Required and supported?, Process the schema.no: refuse itThe schema's $schemathe bookshop's dialectThe meta-schemathe document that URI namesIts $vocabularyrequires format-assertionRequired and supported?all required supportedProcess the schemaprocessed, formats checked

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

Every step as text
  1. The standard dialect. A schema says "$schema": "https://json-schema.org/draft/2020-12/schema". The validator knows that meta-schema without fetching it.
  2. Seven vocabularies. Its $vocabulary lists seven vocabularies, all true, that is required: core, applicator, unevaluated, validation, meta-data, format-annotation and content.
  3. All supported. This validator supports exactly those seven, so every required one is supported, and it processes the schema with their 57 keywords.
  4. The bookshop's dialect. Now a schema whose $schema names the bookshop's own meta-schema. Its $vocabulary requires format-assertion instead of format-annotation.
  5. Refused. The validator does not support format-assertion, and the vocabulary is required, so the specification says it must refuse to process the schema rather than ignore format.
  6. A validator that asserts. A validator that also supports format-assertion processes the same dialect, and then checks every format it meets, as the third line of the program printed.

An optional vocabulary, marked false, is the gentler case: a validator that does not know it should still process the schema, without that vocabulary’s keywords. The 2020-12 meta-schema also still describes four keywords of older drafts, definitions, dependencies, $recursiveAnchor and $recursiveRef, each marked deprecated, with the keyword that replaced it.

Checkpoint

Check: format as an annotation or an assertion · Vocabularies and meta-schemas

  1. Question 1 of 2 A validator follows the 2020-12 defaults. Is "not-an-email" valid against {"type": "string", "format": "email"}?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Yes, because format is only an annotation by default

    Assertion is opt-in: a validator's option, off by default, or a dialect that requires format-assertion.

  2. Question 2 of 2 A meta-schema's $vocabulary marks format-assertion true. Your validator does not support that vocabulary. What must it do with a schema in that dialect?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Refuse to process the schema

    true in $vocabulary means "you must understand this to process me". That rule keeps a schema from meaning less than its author wrote.

An unknown keyword is ignored, so a typo switches a rule off

The core specification tells validators to treat a keyword they do not know as an annotation, and draft-07 said to ignore it. That keeps old validators working with new keywords, and it means a misspelt keyword is not an error: it is simply not a rule. The program has three typos in one schema:

Three misspelt keywords, found and fixed

Python · vocab/typos.py

"""Three misspelt keywords: what the validator does with them, what a keyword check finds, and the fixed schema."""

import json

from minischema import unknown_keywords, validate

SCHEMA = json.loads("""
{
  "type": "object",
  "properties": {
    "id": {"type": "string", "minLength": 1},
    "qty": {"type": "integer", "minimun": 1},
    "colour": {"type": "string"}
  },
  "requird": ["id"],
  "additionalProperites": false,
  "x-owner": "catalogue team"
}
""")
DOCUMENTS = ['{}', '{"id": "BK-101", "qty": 0}', '{"id": "BK-101", "qty": 2, "giftwrap": true}']
for text in DOCUMENTS:
    errors = validate(SCHEMA, json.loads(text))[0]
    print(f"{text}: {'valid' if not errors else f'{len(errors)} errors'}")

print()
found = unknown_keywords(SCHEMA)
for where, close in found:
    print(f"unknown keyword {where}: " + (f'did you mean "{close}"?' if close else "no keyword is close"))

fixed = json.loads(json.dumps(SCHEMA).replace('"minimun"', '"minimum"').replace('"requird"', '"required"')
                   .replace('"additionalProperites"', '"additionalProperties"'))
print()
print("after the three fixes:", "no unknown keywords" if not unknown_keywords(fixed) else unknown_keywords(fixed))
for text in DOCUMENTS:
    for at, message in validate(fixed, json.loads(text))[0]:
        place = at if at else '""'
        print(f"{text}: error at {place}: {message}")

Output

{}: valid
{"id": "BK-101", "qty": 0}: valid
{"id": "BK-101", "qty": 2, "giftwrap": true}: valid

unknown keyword /properties/qty/minimun: did you mean "minimum"?
unknown keyword /requird: did you mean "required"?
unknown keyword /additionalProperites: did you mean "additionalProperties"?

after the three fixes: no unknown keywords
{}: error at "": missing required property "id"
{"id": "BK-101", "qty": 0}: error at /qty: 0 is below the minimum of 1
{"id": "BK-101", "qty": 2, "giftwrap": true}: error at /giftwrap: property "giftwrap" is not allowed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typos.py

JavaScript · vocab/typos.mjs

// Three misspelt keywords: what the validator does with them, what a keyword check finds, and the fixed schema.
import { unknownKeywords, validate } from './minischema.mjs';

const SCHEMA = JSON.parse(`
{
  "type": "object",
  "properties": {
    "id": {"type": "string", "minLength": 1},
    "qty": {"type": "integer", "minimun": 1},
    "colour": {"type": "string"}
  },
  "requird": ["id"],
  "additionalProperites": false,
  "x-owner": "catalogue team"
}
`);
const DOCUMENTS = ['{}', '{"id": "BK-101", "qty": 0}', '{"id": "BK-101", "qty": 2, "giftwrap": true}'];
for (const text of DOCUMENTS) {
  const errors = validate(SCHEMA, JSON.parse(text))[0];
  console.log(`${text}: ${errors.length ? `${errors.length} errors` : 'valid'}`);
}

console.log();
for (const [where, close] of unknownKeywords(SCHEMA)) {
  console.log(`unknown keyword ${where}: ${close ? `did you mean "${close}"?` : 'no keyword is close'}`);
}

const fixed = JSON.parse(JSON.stringify(SCHEMA).replace('"minimun"', '"minimum"').replace('"requird"', '"required"')
  .replace('"additionalProperites"', '"additionalProperties"'));
console.log();
console.log('after the three fixes:', unknownKeywords(fixed).length ? unknownKeywords(fixed) : 'no unknown keywords');
for (const text of DOCUMENTS) {
  for (const [at, message] of validate(fixed, JSON.parse(text))[0]) console.log(`${text}: error at ${at || '""'}: ${message}`);
}

Output

{}: valid
{"id": "BK-101", "qty": 0}: valid
{"id": "BK-101", "qty": 2, "giftwrap": true}: valid

unknown keyword /properties/qty/minimun: did you mean "minimum"?
unknown keyword /requird: did you mean "required"?
unknown keyword /additionalProperites: did you mean "additionalProperties"?

after the three fixes: no unknown keywords
{}: error at "": missing required property "id"
{"id": "BK-101", "qty": 0}: error at /qty: 0 is below the minimum of 1
{"id": "BK-101", "qty": 2, "giftwrap": true}: error at /giftwrap: property "giftwrap" is not allowed

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node typos.mjs

All three documents passed, although the schema’s author meant to require id, to refuse 0 copies and to close the object. The keyword finder walked the schema the way a validator does, into subschemas but never into data, and found minimun, requird and additionalProperites, each with the keyword one or two edits away. With the three fixes, each document got the error its author intended.

Misspell one more keyword

Press Edit, change "minLength": 1 under id to "minLenght": 1, and run it. The finder reports a fourth unknown keyword and suggests minLength.

The finder left x-owner alone. The JSON Schema team’s plan for a stable specification sets names that start with x- aside for your own use, and promises that no future keyword will start with them, so x- is the safe prefix for metadata such as an owner. The finder also stayed out of colour: a name under properties is a member name, not a keyword.

Ajv’s strict mode, which is on by default, refuses to compile a schema with an unknown keyword, which catches these typos at the cost of rejecting other tools’ annotations until you register them. Run a keyword check like this one in continuous integration, whatever validator you use.

JSON Schema Validator Reports each unknown keyword of a schema with a did-you-mean suggestion, and checks formats unless you untick Check formats.

Choose a validator by its test results, its defaults and where it runs

The JSON Schema project keeps the JSON-Schema-Test-Suite, a set of JSON files of test cases for six drafts, from draft-03 to 2020-12. Each file is an array of cases, each case a description, a schema and its tests, and each test a description, the data and whether it is valid. Format tests sit apart, under optional/format, because asserting formats is optional. Bowtie runs many validators on shared tests, and its report at bowtie.report shows how well each one complies with the specification and which tests it fails. This program runs six cases of its own, written in the suite’s file format, against the lesson’s validator:

Cases in the test suite's format, with and without formats

Python · vocab/suite.py

"""Cases in the JSON-Schema-Test-Suite's file format, run against the lesson's validator, with and without formats."""

from minischema import validate

REQUIRED = [
    {"description": "integer and a zero fraction", "schema": {"type": "integer"},
     "tests": [{"description": "1.0 is an integer", "data": 1.0, "valid": True},
               {"description": "1.5 is not", "data": 1.5, "valid": False}]},
    {"description": "minLength counts characters", "schema": {"minLength": 2},
     "tests": [{"description": "one emoji is one character", "data": "📚", "valid": False},
               {"description": "two letters", "data": "ab", "valid": True}]},
    {"description": "required", "schema": {"required": ["id"]},
     "tests": [{"description": "a missing member", "data": {}, "valid": False},
               {"description": "a value that is not an object", "data": "id", "valid": True}]},
]
OPTIONAL_FORMAT = [
    {"description": "email", "schema": {"format": "email"},
     "tests": [{"description": "an ordinary address", "data": "asha@example.com", "valid": True},
               {"description": "no @ at all", "data": "not-an-email", "valid": False}]},
    {"description": "date-time", "schema": {"format": "date-time"},
     "tests": [{"description": "with an offset", "data": "2025-03-14T10:30:00+05:30", "valid": True},
               {"description": "a leap day in a common year", "data": "2025-02-29T10:30:00Z", "valid": False}]},
]


def run(cases, asserted):
    """(tests passed, tests run, descriptions of the tests that failed)."""
    passed, failed, total = 0, [], 0
    for case in cases:
        for test in case["tests"]:
            total += 1
            valid = not validate(case["schema"], test["data"], assert_formats=asserted)[0]
            if valid == test["valid"]:
                passed += 1
            else:
                failed.append(f"{case['description']}: {test['description']}")
    return passed, total, failed


for label, cases, asserted in [("required tests", REQUIRED, False),
                               ("optional format tests, formats as annotations", OPTIONAL_FORMAT, False),
                               ("optional format tests, formats asserted", OPTIONAL_FORMAT, True)]:
    passed, total, failed = run(cases, asserted)
    print(f"{label}: {passed} of {total} pass")
    for name in failed:
        print(f"  failed: {name}")

Output

required tests: 6 of 6 pass
optional format tests, formats as annotations: 2 of 4 pass
  failed: email: no @ at all
  failed: date-time: a leap day in a common year
optional format tests, formats asserted: 4 of 4 pass

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 suite.py

JavaScript · vocab/suite.mjs

// Cases in the JSON-Schema-Test-Suite's file format, run against the lesson's validator, with and without formats.
import { validate } from './minischema.mjs';

const REQUIRED = [
  { description: 'integer and a zero fraction', schema: { type: 'integer' },
    tests: [{ description: '1.0 is an integer', data: 1.0, valid: true }, { description: '1.5 is not', data: 1.5, valid: false }] },
  { description: 'minLength counts characters', schema: { minLength: 2 },
    tests: [{ description: 'one emoji is one character', data: '📚', valid: false }, { description: 'two letters', data: 'ab', valid: true }] },
  { description: 'required', schema: { required: ['id'] },
    tests: [{ description: 'a missing member', data: {}, valid: false }, { description: 'a value that is not an object', data: 'id', valid: true }] },
];
const OPTIONAL_FORMAT = [
  { description: 'email', schema: { format: 'email' },
    tests: [{ description: 'an ordinary address', data: 'asha@example.com', valid: true }, { description: 'no @ at all', data: 'not-an-email', valid: false }] },
  { description: 'date-time', schema: { format: 'date-time' },
    tests: [{ description: 'with an offset', data: '2025-03-14T10:30:00+05:30', valid: true },
      { description: 'a leap day in a common year', data: '2025-02-29T10:30:00Z', valid: false }] },
];

// [tests passed, tests run, descriptions of the tests that failed].
function run(cases, asserted) {
  let passed = 0;
  let total = 0;
  const failed = [];
  for (const c of cases) {
    for (const t of c.tests) {
      total += 1;
      const valid = !validate(c.schema, t.data, asserted)[0].length;
      if (valid === t.valid) passed += 1;
      else failed.push(`${c.description}: ${t.description}`);
    }
  }
  return [passed, total, failed];
}

for (const [label, cases, asserted] of [
  ['required tests', REQUIRED, false],
  ['optional format tests, formats as annotations', OPTIONAL_FORMAT, false],
  ['optional format tests, formats asserted', OPTIONAL_FORMAT, true],
]) {
  const [passed, total, failed] = run(cases, asserted);
  console.log(`${label}: ${passed} of ${total} pass`);
  for (const name of failed) console.log(`  failed: ${name}`);
}

Output

required tests: 6 of 6 pass
optional format tests, formats as annotations: 2 of 4 pass
  failed: email: no @ at all
  failed: date-time: a leap day in a common year
optional format tests, formats asserted: 4 of 4 pass

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node suite.mjs

The required tests all passed. The optional format tests failed twice with formats as annotations, on exactly the values that should fail, and passed in full when formats were asserted. Read a validator’s results the same way: a failure under optional/format may be the specification’s default, and a failure in a required test is a bug in the dialect you need.

Beyond the results, three questions settle most choices:

  1. The draft. Ajv needs its 2020-12 class, imported from ajv/dist/2020, and one instance cannot mix it with earlier drafts; Python’s jsonschema picks its class from $schema.
  2. Where it runs. Ajv turns schemas into JavaScript code, so a page whose Content Security Policy forbids 'unsafe-eval' needs Ajv’s validators compiled in advance, its standalone code, or a validator that interprets the schema instead.
  3. What the errors say. The specification describes output with the instance location and the keyword location of every error; prefer validators that report both, and collect every error rather than the first.

Key idea

Know a validator’s defaults before you trust it: whether it asserts formats, whether it refuses unknown keywords, and which tests of your draft it fails, then pin those choices in your own tests.

Checkpoint

Check: Unknown and misspelt keywords · Choosing and testing a validator

  1. Question 1 of 2 A schema says "requird": ["id"]. Why does the empty object {} pass it in a validator that follows the specification?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: requird is not a keyword, so it is treated as an annotation and checks nothing

    Unknown keywords are annotations by the core specification. A keyword check in continuous integration, or Ajv's strict mode, is how such typos are caught.

  2. Question 2 of 2 A validator, run with its default options, fails two tests under optional/format of the 2020-12 suite and passes every required test. What is the most likely reading?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It treats format as an annotation, as the default dialect does, so invalid formats pass

    Read results per dialect and per option: optional tests describe optional behaviour.

Practise: find the keywords no vocabulary defines

The exercise asks for the keyword finder of the third section: a function that walks a schema into its subschemas, never into its data, and reports every name that no 2020-12 vocabulary defines, with a suggestion. The tests use the lesson’s schema, member names that look like typos, nested subschemas and pointers that need escapes:

Exercise · Medium · Python, JavaScript

Find the keywords no vocabulary defines

Write unknown_keywords(schema) in keywords.py (Python) and unknownKeywords(schema) in keywords.mjs (JavaScript). It returns, in the order they appear, one pair for every name in the schema that no 2020-12 vocabulary defines: the JSON Pointer to that name, and the keyword closest(name) suggests (None or null when none is close). The starter gives you KEYWORDS, the 57 keywords of the 2020-12 dialect, and closest.

Walk only into subschemas, never into data:

  • properties, patternProperties, $defs and dependentSchemas hold an object whose values are subschemas; the names of that object are member names or pattern names, not keywords.
  • allOf, anyOf, oneOf and prefixItems hold an array of subschemas.
  • items, contains, not, if, then, else, additionalProperties, propertyNames, unevaluatedItems, unevaluatedProperties and contentSchema hold one subschema, which may be true or false.
  • Everything else, such as enum, const, default and examples, holds data: never look inside it.

Names that start with x- are your own and are never reported. Build pointers as RFC 6901 does: ~ is written ~0 and / is written ~1, so a property named a/b is the token a~1b.

Python · Starter code · keywords.py

KEYWORDS = set("""
$id $schema $ref $anchor $dynamicRef $dynamicAnchor $vocabulary $comment $defs
allOf anyOf oneOf not if then else dependentSchemas prefixItems items contains properties patternProperties
additionalProperties propertyNames unevaluatedItems unevaluatedProperties
type enum const multipleOf maximum exclusiveMaximum minimum exclusiveMinimum maxLength minLength pattern maxItems
minItems uniqueItems maxContains minContains maxProperties minProperties required dependentRequired
title description default deprecated readOnly writeOnly examples format contentEncoding contentMediaType contentSchema
""".split())  # the 57 keywords of the 2020-12 dialect


def distance(a, b):
    """Levenshtein distance: the fewest single-character edits that turn a into b."""
    row = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        prev, row[0] = row[0], i
        for j, cb in enumerate(b, 1):
            prev, row[j] = row[j], min(row[j] + 1, row[j - 1] + 1, prev + (ca != cb))
    return row[-1]


def closest(name):
    """The keyword nearest to name when it is at most 2 edits away, else None."""
    best = min(sorted(KEYWORDS), key=lambda k: distance(name, k))
    return best if distance(name, best) <= 2 else None


def unknown_keywords(schema):
    """[(JSON Pointer to the name, closest(name))] for every unknown keyword, in the order they appear."""
    return []
The sample tests · test_keywords.py
from keywords import unknown_keywords

LESSON = {
    "type": "object",
    "properties": {"id": {"type": "string", "minLength": 1}, "qty": {"type": "integer", "minimun": 1},
                   "colour": {"type": "string"}},
    "requird": ["id"],
    "additionalProperites": False,
    "x-owner": "catalogue team",
}


def test_clean_schema():
    """reports nothing for a schema of known keywords"""
    assert unknown_keywords({"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}) == []


def test_lesson_schema():
    """finds the lesson's three typos, in order, with their suggestions"""
    assert unknown_keywords(LESSON) == [("/properties/qty/minimun", "minimum"), ("/requird", "required"),
                                        ("/additionalProperites", "additionalProperties")]


def test_member_names():
    """never reports member names under properties or $defs, but looks inside their subschemas"""
    assert unknown_keywords({"properties": {"requird": {"type": "string"}, "colour": {}}}) == []
    assert unknown_keywords({"$defs": {"requird": {"enum": [1], "maximun": 2}}}) == [("/$defs/requird/maximun", "maximum")]


def test_lists_and_single_subschemas():
    """walks into arrays of subschemas and single subschemas, true and false included"""
    schema = {"allOf": [{"tpye": "string"}], "items": {"maxLenght": 3}, "not": True, "else": False}
    assert unknown_keywords(schema) == [("/allOf/0/tpye", "type"), ("/items/maxLenght", "maxLength")]


def test_own_names_and_far_typos():
    """leaves x- names alone, and suggests nothing for a name far from every keyword"""
    assert unknown_keywords({"x-owner": "team", "frobnicate": 1}) == [("/frobnicate", None)]


def test_pointer_escapes():
    """escapes / and ~ in pointer tokens"""
    assert unknown_keywords({"properties": {"a/b": {"minimun": 1}, "c~d": {"tpye": "string"}}}) == [
        ("/properties/a~1b/minimun", "minimum"), ("/properties/c~0d/tpye", "type")]


def test_data_is_not_walked():
    """never looks inside enum, const, default or examples"""
    assert unknown_keywords({"enum": [{"tpye": 1}], "const": {"requird": 1}, "examples": [{"minimun": 0}],
                             "default": {"x": 1}}) == []

JavaScript · Starter code · keywords.mjs

export const KEYWORDS = new Set(`
$id $schema $ref $anchor $dynamicRef $dynamicAnchor $vocabulary $comment $defs
allOf anyOf oneOf not if then else dependentSchemas prefixItems items contains properties patternProperties
additionalProperties propertyNames unevaluatedItems unevaluatedProperties
type enum const multipleOf maximum exclusiveMaximum minimum exclusiveMinimum maxLength minLength pattern maxItems
minItems uniqueItems maxContains minContains maxProperties minProperties required dependentRequired
title description default deprecated readOnly writeOnly examples format contentEncoding contentMediaType contentSchema
`.trim().split(/\s+/)); // the 57 keywords of the 2020-12 dialect

// Levenshtein distance: the fewest single-character edits that turn a into b.
export function distance(a, b) {
  const row = Array.from({ length: b.length + 1 }, (_, j) => j);
  for (let i = 1; i <= a.length; i++) {
    let prev = row[0];
    row[0] = i;
    for (let j = 1; j <= b.length; j++) {
      const old = row[j];
      row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] !== b[j - 1] ? 1 : 0));
      prev = old;
    }
  }
  return row[b.length];
}

const SORTED = [...KEYWORDS].sort();

// The keyword nearest to name when it is at most 2 edits away, else null.
export function closest(name) {
  let best = SORTED[0];
  for (const k of SORTED) if (distance(name, k) < distance(name, best)) best = k;
  return distance(name, best) <= 2 ? best : null;
}

// [[JSON Pointer to the name, closest(name)]] for every unknown keyword, in the order they appear.
export function unknownKeywords(schema) {
  return [];
}
The sample tests · keywords.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { unknownKeywords } from './keywords.mjs';

const LESSON = {
  type: 'object',
  properties: { id: { type: 'string', minLength: 1 }, qty: { type: 'integer', minimun: 1 }, colour: { type: 'string' } },
  requird: ['id'],
  additionalProperites: false,
  'x-owner': 'catalogue team',
};

test('reports nothing for a schema of known keywords', () => {
  assert.deepEqual(unknownKeywords({ type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }), []);
});

test('finds the lesson\'s three typos, in order, with their suggestions', () => {
  assert.deepEqual(unknownKeywords(LESSON), [['/properties/qty/minimun', 'minimum'], ['/requird', 'required'], ['/additionalProperites', 'additionalProperties']]);
});

test('never reports member names under properties or $defs, but looks inside their subschemas', () => {
  assert.deepEqual(unknownKeywords({ properties: { requird: { type: 'string' }, colour: {} } }), []);
  assert.deepEqual(unknownKeywords({ $defs: { requird: { enum: [1], maximun: 2 } } }), [['/$defs/requird/maximun', 'maximum']]);
});

test('walks into arrays of subschemas and single subschemas, true and false included', () => {
  const schema = { allOf: [{ tpye: 'string' }], items: { maxLenght: 3 }, not: true, else: false };
  assert.deepEqual(unknownKeywords(schema), [['/allOf/0/tpye', 'type'], ['/items/maxLenght', 'maxLength']]);
});

test('leaves x- names alone, and suggests nothing for a name far from every keyword', () => {
  assert.deepEqual(unknownKeywords({ 'x-owner': 'team', frobnicate: 1 }), [['/frobnicate', null]]);
});

test('escapes / and ~ in pointer tokens', () => {
  assert.deepEqual(unknownKeywords({ properties: { 'a/b': { minimun: 1 }, 'c~d': { tpye: 'string' } } }), [
    ['/properties/a~1b/minimun', 'minimum'], ['/properties/c~0d/tpye', 'type']]);
});

test('never looks inside enum, const, default or examples', () => {
  assert.deepEqual(unknownKeywords({ enum: [{ tpye: 1 }], const: { requird: 1 }, examples: [{ minimun: 0 }], default: { x: 1 } }), []);
});
A hint

Write one recursive function with a second argument, the pointer of the schema it is looking at, starting from "". For each name in the schema: if it is not in KEYWORDS and does not start with x-, record it; otherwise, if it is one of the three kinds of subschema keyword, call yourself on each subschema with the pointer one or two tokens longer (/properties/qty, /allOf/0, /items). A value that is not an object, such as true, has no keywords.

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 answer the lesson’s questions on formats, vocabularies, unknown keywords and validators. A miss brings a question on the same idea back before the session ends:

Practice that adapts to you

Practise this lesson

14 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 With formats asserted, what happens to a value under "format": "phone", which the specification does not define?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: It fails as an unknown format

    Custom formats need a custom check registered with the validator, or a pattern instead.

  2. Question 2 of 14 With formats asserted, why is a date-time on a leap day of a common year refused, although its digits have the right shape?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: RFC 3339 limits the day to the days of that month in that year

    A format is a standard's rule, not only a pattern; that is why validators that implement formats can differ.

  3. Question 3 of 14 A schema says "contentMediaType": "application/json" for a string. Does a 2020-12 validator parse the string by default?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: No, content keywords are annotations, and must not be decoded or checked by default

    contentEncoding, contentMediaType and contentSchema describe a string's contents for applications, as format describes its shape.

  4. Question 4 of 14 A Python service and a Node.js service validate the same email addresses. How do you make sure they agree?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Assert formats in both and run the same good and bad values through both in tests

    The Tradeoffs table adds a caveat to asserting in the validator when several languages read the data: test the same values in each.

  5. Question 5 of 14 In a meta-schema's $vocabulary, what does true after a vocabulary's URI mean?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: The vocabulary is required to process schemas of this dialect

    All seven vocabularies of the 2020-12 meta-schema are true.

  6. Question 6 of 14 Which vocabulary of the 2020-12 meta-schema holds the keyword format?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: format-annotation

    The program listed seven vocabularies and 57 keywords; format-annotation brings exactly one, format.

  7. Question 7 of 14 A dialect lists a vocabulary of yours with false. A validator that has never heard of it reads a schema of that dialect. What happens?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: It should still process the schema, without that vocabulary's keywords

    Use false for extras that only some tools need, such as documentation hints, and true for rules the schema cannot do without.

  8. Question 8 of 14 What does the 2020-12 core specification tell validators to do with a keyword they do not know?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Treat it as an annotation, whose value is the keyword's value

    This is why a typo is silent: a misspelt keyword becomes information instead of a rule.

  9. Question 9 of 14 You want to record which team owns a schema, inside the schema itself. Which name is safest?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: "x-owner"

    The keyword finder skipped x-owner for this reason, and MySmartCoPilot's validator does not report x- names either.

  10. Question 10 of 14 What does Ajv do by default with a schema that contains "requird"?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: It refuses to compile the schema, because strict mode rejects unknown keywords

    Strict mode costs something too: other tools' annotations must be registered with addKeyword before Ajv accepts them.

  11. Question 11 of 14 A schema has "properties": {"requird": {"type": "boolean"}}. Should a keyword check report requird?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: No, a name under properties is a member name, not a keyword

    A keyword check must know where subschemas are, exactly as a validator does; the exercise tests this case.

  12. Question 12 of 14 What does one test case of the JSON-Schema-Test-Suite hold?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: A description, a schema and tests, each test a description, the data and whether it is valid

    Because each test is just data and an expected answer, any validator in any language can run the suite.

  13. Question 13 of 14 A web page's Content Security Policy forbids 'unsafe-eval'. What does Ajv's documentation say to do?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: Compile the validators in advance, as standalone code, or relax the policy, which it does not recommend

    An interpreting validator, which walks the schema as the lesson's validators do, is the other way to meet such a policy.

  14. Question 14 of 14 How do you validate 2020-12 schemas with Ajv?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Import the class from ajv/dist/2020, in an instance used only for 2020-12

    Pick the class by draft, then the options: strictness, formats and allErrors.

Interview questions

Warm-up (fresher to 2–5 years): why does {"format": "email"} accept "not-an-email" in a validator that follows 2020-12?

Because in the default 2020-12 dialect format belongs to the format-annotation vocabulary: the validator records that the value is meant to be an email and checks nothing. The specification makes assertion opt-in: a validator may offer an option to check formats, off by default, or a dialect may require the format-assertion vocabulary. Ajv checks formats only with the ajv-formats plugin, and Python’s jsonschema only with a format checker. If bad values must fail, turn assertion on and test it with a bad value.

Two harder questions follow, about a format check that differs between two services and about a schema typo that let bad data into production. 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. Mid to senior

    A Python service accepts an order timestamp that a Node.js service refuses, although both use the same schema with "format": "date-time". What could cause the difference, and how would you make the two services agree?

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

  2. Fresher to mid

    A schema change added "maxLenght": 120 to a gift note, and it shipped. Weeks later a note of 5,000 characters broke the label printer. Why did no test catch it, and what would you add to the pipeline?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

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

  1. Why does {"format": "email"} accept "not-an-email" in 2020-12?

    In the default dialect format only annotates. It asserts when the validator's option is switched on, or when a dialect requires the format-assertion vocabulary.

  2. How do you make Ajv and Python's jsonschema check formats?

    Ajv with the ajv-formats plugin, jsonschema with a format_checker. Some formats need extra packages in jsonschema, and without them the check passes, so prove it with a failing test.

  3. What does $vocabulary in a meta-schema tell you?

    Which vocabularies a dialect uses, each marked required or optional. The 2020-12 meta-schema lists seven, all required, which define its 57 keywords, and a validator that lacks a required one must refuse the schema.

  4. What happens to a misspelt keyword such as "maxLenght"?

    It is unknown, so it is kept as an annotation and checks nothing: the rule is silently off. Ajv's strict mode or a keyword check in CI catches it.

  5. How do you choose a JSON Schema validator?

    By its results on the JSON-Schema-Test-Suite for your draft, with the optional format tests read apart, by its defaults, by where it runs, and by whether its errors carry both locations.

Key takeaways

  • In the default 2020-12 dialect format is an annotation that checks nothing; it asserts only when a validator’s option is on or a dialect requires format-assertion, and a format check must be proved by a failing test.
  • A meta-schema’s $vocabulary names the vocabularies of a dialect: the 2020-12 one requires seven, with 57 keywords, and a validator that lacks a required vocabulary must refuse the schema.
  • An unknown keyword is treated as an annotation, so a misspelt rule silently stops checking; a keyword check in continuous integration, or Ajv’s strict mode, catches it, and x- names are set aside for your own metadata.
  • Choose a validator by its results on the JSON-Schema-Test-Suite for your draft, read with the optional format tests apart, by its defaults, by where it runs and by whether its errors carry both locations.

Terms in this lesson

meta-schema
A JSON Schema that describes schemas: the document a $schema URI names. Its $vocabulary lists the vocabularies of its dialect, and it checks that a schema uses their keywords correctly.

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.