JSON Schema is a vocabulary, written in JSON, for describing what a JSON document must look like: which types are allowed, which properties are required, how long a string can be. A validator takes a schema and an instance and answers “valid” or “invalid, and here is why”. The current version is 2020-12, published as JSON Schema Core and JSON Schema Validation; OpenAPI 3.1 uses it for request and response bodies.

The keywords are easy. The trouble starts when two validators, or two drafts, give different answers for the same schema. This guide covers the drafts and what changed between them, the keywords you will use every day, and one schema run through three validators — Ajv, Python’s jsonschema and Go’s santhosh-tekuri/jsonschema — including the format keyword, where they disagree most.

A schema and what it rejects

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "email", "role"],
  "properties": {
    "id": { "type": "integer", "minimum": 1 },
    "email": { "type": "string", "format": "email" },
    "role": { "enum": ["admin", "editor", "viewer"] },
    "tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
  },
  "additionalProperties": false
}

$schema says which draft the schema is written in. required lists the properties that must be present; everything in properties is otherwise optional. additionalProperties: false rejects keys that are not listed, which catches typos such as emial. Now an instance that breaks every rule:

{ "id": 0, "email": "not-an-email", "role": "owner", "tags": ["a", "a"], "extra": true }

With Ajv 8.18.0, ajv-formats 3.0.1 and allErrors: true, this script prints five errors:

// check-user.mjs — run: node check-user.mjs   (needs: npm install ajv ajv-formats)
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';

const schema = {
  $schema: 'https://json-schema.org/draft/2020-12/schema',
  type: 'object',
  required: ['id', 'email', 'role'],
  properties: {
    id: { type: 'integer', minimum: 1 },
    email: { type: 'string', format: 'email' },
    role: { enum: ['admin', 'editor', 'viewer'] },
    tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
  },
  additionalProperties: false,
};
const data = { id: 0, email: 'not-an-email', role: 'owner', tags: ['a', 'a'], extra: true };

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
console.log(validate(data));
for (const e of validate.errors) console.log(e.instancePath || '(root)', e.message);
// false
// (root) must NOT have additional properties
// /id must be >= 1
// /email must match format "email"
// /role must be equal to one of the allowed values
// /tags must NOT have duplicate items (items ## 1 and 0 are identical)

Without allErrors, Ajv stops at the first failure and reports only the extra property. The paths (/id, /tags) are JSON Pointers (RFC 6901) into the instance.

How the drafts evolved

JSON Schema was developed as a series of IETF Internet-Drafts, which is why versions are called “drafts”. The first meta-schemas were numbered; since 2019 they are named by date. The specification links page lists every version.

VersionCore document and date$schema URIChanges that affect existing schemas
Draft 4draft-zyp-json-schema-04, 2013-01-31http://json-schema.org/draft-04/schema#required became an array of names
Draft 6draft-wright-json-schema-01, 2017-04-15http://json-schema.org/draft-06/schema#id renamed $id; exclusiveMinimum / exclusiveMaximum changed from booleans to numbers; added const, contains, propertyNames, examples (release notes)
Draft 7draft-handrews-json-schema-01, 2018-03-19http://json-schema.org/draft-07/schema#Added if / then / else, $comment, writeOnly; readOnly moved from Hyper-Schema; no keyword changed behavior (release notes)
2019-09draft-handrews-json-schema-02, 2019-09-16https://json-schema.org/draft/2019-09/schemadefinitions → $defs; dependencies split into dependentRequired and dependentSchemas; added unevaluatedProperties; format no longer an assertion by default (release notes)
2020-12draft-bhutton-json-schema-00, 2020-12-08https://json-schema.org/draft/2020-12/schemaArray form of items → prefixItems, and additionalItems → items; $recursiveRef → $dynamicRef (release notes)

Two of these changes break schemas silently or loudly, and we tested both:

  • items as an array in 2020-12. {"type": "array", "items": [{"type": "integer"}]} with the 2020-12 $schema is rejected as an invalid schema by all three validators (Ajv: schema is invalid: data/items must be object,boolean). Rename it to prefixItems.
  • 2020-12 keywords in a draft-07 schema. A draft-07 schema with prefixItems is worse: draft-07 does not know the keyword, so Python jsonschema, Go v6 and Ajv in non-strict mode ignore it, and ["x"] passes. Only Ajv’s default strict mode stops with strict mode: unknown keyword: "prefixItems".
  • Boolean exclusiveMinimum. "minimum": 0, "exclusiveMinimum": true is draft-04 syntax. Under 2020-12 all three report an invalid schema; write "exclusiveMinimum": 0 instead.

The keywords you will use every day

PurposeKeywordsNotes
Typetypestring, number, integer, boolean, object, array, null, or an array of these
Fixed valuesenum, constconst is a one-value enum (draft 6 and later)
Objectsproperties, required, additionalProperties, patternProperties, propertyNames, minProperties, maxProperties, dependentRequiredadditionalProperties only sees properties and patternProperties in the same schema object
Arraysitems, prefixItems, contains, minItems, maxItems, uniqueItems2020-12 names; see the table above for older drafts
StringsminLength, maxLength, pattern, formatpattern is an ECMA-262 regular expression and is not anchored, so add ^ and $
Numbersminimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
CombiningallOf, anyOf, oneOf, not, if / then / elseoneOf fails when more than one branch matches
Reuse$defs, $ref, $id, $anchor$ref can point inside the same document or to another URI
Strict closingunevaluatedProperties, unevaluatedItemsLike additionalProperties, but they also see properties validated in allOf, $ref and conditional branches

The additionalProperties note is the most common surprise. If you split an object into allOf: [{ "$ref": "#/$defs/base" }, { "properties": { ... } }] and add "additionalProperties": false at the top level, every property is rejected, because the top-level schema object has no properties of its own. "unevaluatedProperties": false (2019-09 and later) is the keyword for that case.

Two more details that cause bugs. integer means a number with no fractional part, so 1.0 is a valid integer in all three validators (Python receives it as the float 1.0 and still accepts it). And JSON Schema does not limit number size, but your parser does: JavaScript’s JSON.parse turns 9007199254740993 into 9007199254740992, so Ajv validates a different number from the one the sender wrote. "maximum": 9007199254740991 rejects such values (Ajv reports must be <= 9007199254740991), but the safer fix is to send 64-bit IDs as strings.

format: an annotation, unless you turn checking on

format is where validators disagree most, and the specification allows it. The 2020-12 Validation document, section 7.2.1, says that under the default format-annotation vocabulary an implementation may also check formats, but this “MUST be disabled by default”. Draft 7 left checking optional. In practice:

Validator (version tested)Default behavior for "format": "email"How to check formats
Ajv 8.18.0Strict mode throws unknown format "email" ignored in schema at path "#/properties/email" at compile time; with strict: false the format is ignoredAdd ajv-formats
Python jsonschema 4.26.0Not checked, in any draftPass format_checker=Draft202012Validator.FORMAT_CHECKER; install jsonschema[format] for date-time, uri and others
Go santhosh-tekuri/jsonschema v6.0.3Checked for draft-07 schemas; not checked for 2019-09, 2020-12 or schemas without $schemaCall compiler.AssertFormat()
ZeroTool JSON Schema ValidatorChecked in every draft while Check format is on (the default)Uses ajv-formats for 15 formats; turn the switch off for the 2020-12 default

Even with checking on, the three implementations accept different strings. With formats enabled (Python with only the base install, which registers checkers for date, email, idn-email, ipv4, ipv6, regex and uuid):

ValueformatAjv + ajv-formatsjsonschema (base install)Go v6 + AssertFormat()
not-an-emailemailinvalidinvalidinvalid
a@bemailinvalidvalidvalid
user@localhostemailinvalidvalidvalid
2026-02-30dateinvalidinvalidinvalid
2026-10-02 10:00:00date-timeinvalidvalid (not checked)invalid
example.com/pathuriinvalidvalid (not checked)invalid
192.168.001.1ipv4invalidinvalidinvalid

Python’s email check only looks for an @, and without the optional packages it does not check date-time or uri at all. Go v6 explains each failure ('2026-10-02 10:00:00' is not valid date-time: less than 20 characters long). If a format matters for correctness, for example an RFC 3339 timestamp your database will parse, check it in the application as well, or pin the validator and its format options in every service that validates the same data.

The same schema in Python and Go

Python’s jsonschema reports the same four structural errors as Ajv and adds the format error only when you pass a format checker:

# check_user.py — run: python check_user.py   (needs: pip install jsonschema)
from jsonschema import Draft202012Validator

schema = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "required": ["id", "email", "role"],
    "properties": {
        "id": {"type": "integer", "minimum": 1},
        "email": {"type": "string", "format": "email"},
        "role": {"enum": ["admin", "editor", "viewer"]},
        "tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
    },
    "additionalProperties": False,
}
data = {"id": 0, "email": "not-an-email", "role": "owner", "tags": ["a", "a"], "extra": True}

for label, kwargs in [("plain", {}), ("format checker", {"format_checker": Draft202012Validator.FORMAT_CHECKER})]:
    errors = sorted(Draft202012Validator(schema, **kwargs).iter_errors(data), key=lambda e: list(e.path))
    print(label, [e.validator for e in errors])
# plain ['additionalProperties', 'minimum', 'enum', 'uniqueItems']
# format checker ['additionalProperties', 'format', 'minimum', 'enum', 'uniqueItems']

jsonschema.validate(data, schema) raises only the most relevant error; here it was Additional properties are not allowed ('extra' was unexpected). Use iter_errors() when you want them all.

In Go, santhosh-tekuri/jsonschema v6 needs the schema parsed with its own UnmarshalJSON before AddResource:

c := jsonschema.NewCompiler()
c.AssertFormat() // without this, 2020-12 formats are not checked
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(schemaJSON))
if err != nil {
	log.Fatal(err)
}
if err := c.AddResource("user.json", doc); err != nil {
	log.Fatal(err)
}
sch, err := c.Compile("user.json")
if err != nil {
	log.Fatal(err)
}
inst, _ := jsonschema.UnmarshalJSON(strings.NewReader(dataJSON))
if err := sch.Validate(inst); err != nil {
	fmt.Println(err) // one error tree: "- at '/id': minimum: got 0, want 1" and so on
}

With AssertFormat() it printed all five problems, including 'not-an-email' is not valid email: missing @; without it, four.

Checking a schema in the browser

The ZeroTool JSON Schema Validator runs Ajv 8 with ajv-formats in your browser; the schema and data are not uploaded. It reads the draft from $schema (draft-04 to 2020-12) and uses 2020-12 when $schema is missing, the same default as Python jsonschema and Go v6. The menu can force a draft; a $schema from another draft then produces a note instead of a compile error.

All errors are listed, not just the first (Ajv’s allErrors), grouped by the JSON Pointer path in the data, with the line and column and the keyword in the schema. The tool’s own invalid example (Draft-07 user schema, data with an empty name, a negative age, a bad email, an unknown role and an extra key) gives, with Copy errors:

(root) (line 6, column 3): property "extra" is not allowed (additionalProperties)  schema #/additionalProperties
/name (line 2, column 11): must have at least 1 character(s); has 0  schema #/properties/name/minLength
/age (line 3, column 10): must be ≥ 0, but is -5  schema #/properties/age/minimum
/email (line 4, column 12): is not a valid "email"  schema #/properties/email/format
/role (line 5, column 11): must be one of "admin", "user", "guest"  schema #/properties/role/enum

The tool runs Ajv in non-strict mode, so unknown keywords such as prefixItems in a Draft-07 schema are ignored, as in Python and Go, but each one is listed under the result: Unknown keyword "prefixItems" at # is ignored. Formats outside the ajv-formats list (idn-email, iri, custom names) pass and are listed the same way. Where Ajv is known to give a wrong result ($dynamicRef, some unevaluated* combinations, nested $id with relative refs, custom vocabularies) or a number cannot be held exactly in JavaScript (9007199254740993), the tool says “Cannot be determined” with the reason instead of valid or invalid.

Mistakes that pass review

  • Forgetting required. A schema with properties and no required accepts {}.
  • additionalProperties: false next to allOf. It rejects the properties defined in the allOf branches; use unevaluatedProperties.
  • Unanchored pattern. "pattern": "[0-9]{4}" matches abc12345xyz; write "^[0-9]{4}$".
  • Mixing drafts. Keywords from a newer draft are ignored by an older one unless your validator runs in a strict mode.
  • Trusting format. Decide whether formats are checked, write it down, and make every service that validates the same data use the same setting.