All articles
Validators
32 min readBy DevUtilX Team

JSON Validation and JSON Schema: Catching Errors Early

Learn the two layers of JSON validation: syntax errors per RFC 8259 and structure checks with JSON Schema, with tested Ajv and Python examples and real error output.

JSON Validation and JSON Schema: Catching Errors Early

A request arrives at your API, a config file is loaded at startup, or a webhook payload lands in a queue. Somewhere between the network and your business logic, somebody has to answer two questions: is this text even JSON, and if it is, does it have the shape my code expects? Skip either question and the failure shows up later and further away, as Cannot read properties of undefined, a null in a database column, or a support ticket about a silently ignored setting. This guide is for developers who produce or consume JSON, whether in APIs, configuration or data pipelines. You will learn the two layers of validation, the exact syntax errors parsers report and why, how JSON Schema works from first principles, how to run it in Node.js and Python with real output, and how to design, secure and troubleshoot validation in production.

Two layers of validation: syntax and structure

People say "validate the JSON" and mean two different things. Keeping them separate makes every later decision easier.

Layer one is syntax. Is the text a well-formed JSON document according to the grammar in RFC 8259? A parser answers this question. If the text has a trailing comma, a single-quoted string or a stray comment, the parser throws and no data object exists. Syntax validation is binary and has nothing to do with your application.

Layer two is structure, sometimes called semantics. Given a document that parsed, does it describe the thing you expect? Is age a number, is email present, is status one of three allowed words, is the items array non-empty? JSON itself has no way to express these rules. That is the job of a schema language, and the most widely used one is JSON Schema.

A quick example shows why both layers matter:

{"id": "ord_42", "qty": "3"}

This is perfect syntax. A parser accepts it without complaint. But if your code does qty * price, the string "3" may produce surprising results in a loosely typed language, or a crash in a strict one. Only a structural check catches that, and it has to run after the syntax check succeeds.

The order is fixed: parse first, then validate the parsed value against a schema. Many bugs come from collapsing the two, for example by catching every exception from a handler and returning a generic 500, so a client's malformed body looks like a server fault. Good systems report syntax errors and structure errors differently, usually as a 400 with a message for the first and a 422 or a 400 with a list of field errors for the second. HTTP status choices vary between teams, so pick one convention and document it.

What JSON and JSON Schema are, and where they came from

JSON, short for JavaScript Object Notation, is a small text format for structured data built from objects, arrays, strings, numbers, booleans and null. It was derived from a subset of JavaScript literal syntax and popularised in the early 2000s as a lighter alternative to XML for web APIs. It has since been standardised in several documents, and the current IETF specification is RFC 8259. The format is deliberately minimal. There are no comments, no dates, no trailing commas, no way to say "this field is required", and no types beyond the six value kinds.

That minimalism is the reason JSON spread so quickly, and also the reason a second layer was needed. As soon as two teams exchanged JSON, they needed a contract: field names, types, ranges. Early on that contract lived in wiki pages and README files. Several schema proposals appeared, and JSON Schema became the one with the broadest tooling, with implementations in nearly every mainstream language.

JSON Schema is itself a JSON document. You write a schema as an object whose keywords describe constraints, and a validator checks an instance (the data) against it. The specification has evolved through numbered drafts, and the project documentation, including the readable Understanding JSON Schema guide, covers the details. Because I cannot verify exact draft histories from here, I will only state what is safe: several drafts exist, they differ in keyword names and behaviour in some areas, and recent ones identify themselves with a dated URL in the $schema keyword. The most important practical lesson is to declare the draft you target, and to check which drafts your validator library supports. We will come back to this in the section on draft versions.

Layer one in depth: the syntax rules and their errors

RFC 8259 defines the grammar in a handful of rules. Most syntax errors in the wild break one of the following. For each, I will show what a parser says. The messages below come from Node.js 22 (V8's JSON.parse) and Python 3.13, which I ran for this article. Other engines and versions word things differently, and older Node versions give shorter messages such as Unexpected token } in JSON at position 7, so treat the exact text as an example of the kind of message, not a contract.

The common syntax errors

Mistake Example Node.js message Python message
Trailing comma {"a":1,} Expected double-quoted property name in JSON at position 7 Illegal trailing comma before end of object: line 1 column 7 (char 6)
Single quotes {'a':1} Expected property name or '}' in JSON at position 1 Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
Unquoted key {a:1} Expected property name or '}' in JSON at position 1 Expecting property name enclosed in double quotes
Comment {"a":1 // c Expected ',' or '}' after property value in JSON at position 7 Expecting ',' delimiter
NaN literal {"a":NaN} Unexpected token 'N', "{"a":NaN}" is not valid JSON Accepted, returns nan
Invalid escape "\x41" Bad escaped character in JSON at position 7 Invalid \escape
Raw newline in string a literal line break inside quotes Bad control character in string literal in JSON at position 10 Invalid control character
Leading zero {"a":01} Unexpected number in JSON at position 6 Expecting ',' delimiter
Truncated input [1,2 Expected ',' or ']' after array element in JSON at position 4 Expecting ',' delimiter
Extra data {"a":1}} Unexpected non-whitespace character after JSON at position 7 Extra data
Empty input an empty string Unexpected end of JSON input Expecting value

The Python column for rows where I did not run the case is abbreviated from memory of its typical wording, so rely on the Node column and your own parser for exact text. The cases I did run in Python were the trailing comma, single quotes, NaN and duplicate keys.

Notice that the same mistake is described differently by different parsers, and that position numbers count characters from the start of the text, starting at zero. When a parser says "position 7", look at the character at index 7, and also at the character just before it, because the real error is often the preceding comma or quote.

Trailing commas

JavaScript object literals, Python dictionaries and many config languages tolerate a comma after the last element. JSON does not. This is the single most common hand-editing error, and it appears constantly when someone deletes the last line of an object and forgets the comma on the previous one, or the reverse. The parser error points at the closing brace or bracket, not at the comma itself, because the grammar only discovers the problem when it expects another key and finds }.

Single quotes and unquoted keys

JSON strings and object keys must use double quotes. Single quotes, backticks and bare identifiers are all errors. This bites people who copy a JavaScript object literal or a Python repr output, which uses single quotes and True, False and None. If you paste printed Python dictionaries into a JSON consumer, you will see the single-quote error immediately and then a second error for True. The fix is to serialise with a real JSON encoder (json.dumps in Python, JSON.stringify in JavaScript) rather than printing.

Comments

RFC 8259 has no comment syntax. // and /* */ are both errors in standard parsers. Some tools quietly accept them (editors for configuration files, for example, often use a superset format commonly called JSONC or JSON with comments), and that tolerance is where trouble starts: a file works in one tool and fails in another. If you need comments, either use a format that officially supports them, strip them in a documented build step, or add a sibling field such as "_comment", recognising that it becomes real data.

NaN, Infinity and other non-values

JSON numbers are decimal digits with an optional fraction and exponent. There is no NaN, Infinity, -Infinity, undefined, hexadecimal literal or leading plus sign. JSON.stringify in JavaScript turns NaN and Infinity into null rather than throwing, which is data loss disguised as success. In Python, json.dumps by default emits NaN and Infinity, which produces text that strict parsers reject, and json.loads happily accepts them by default. The Python snippet later in this guide shows how to refuse them on input, and json.dumps(..., allow_nan=False) raises on output instead.

Leading zeros and number precision

01 is not a valid JSON number, but 0, 0.1 and -0 are. Beyond syntax, RFC 8259 warns that numbers beyond the range of IEEE 754 double precision may not interoperate. I ran JSON.parse('{"id":12345678901234567890}') in Node and got back 12345678901234567000. The parser accepted the text without error, and the value silently changed. If you carry 64-bit identifiers, send them as strings.

Invalid escapes and control characters

Inside strings, the allowed escapes are \", \\, \/, \b, \f, \n, \r, \t and \uXXXX. Anything else, such as \x41 or a Windows path like "C:\temp" (where \t is valid and becomes a tab, while \d would be an error), is a problem. Raw control characters, meaning code points below U+0020 such as a literal tab or newline, must be escaped. Pasting a multi-line text into a string without escaping the line breaks produces "Bad control character".

The byte order mark

Some Windows editors save UTF-8 files with a byte order mark, the three bytes EF BB BF, which decode to the invisible character U+FEFF. RFC 8259 says that parsers must not add a BOM to output and that implementations may ignore one on input, so behaviour varies. In Node, JSON.parse('\uFEFF{"a":1}') failed for me with Unexpected token '', "{"a":1}" is not valid JSON, where the quoted token looks empty because the character is invisible. When a file looks perfect and still fails at position 0, suspect a BOM. In Python, open the file with encoding="utf-8-sig" to strip it.

Duplicate keys: valid syntax, undefined meaning

This one does not produce an error at all. {"role":"user","role":"admin"} is accepted by the grammar. RFC 8259 says object names SHOULD be unique and that when names are not unique, the behaviour of receiving software is unpredictable. In my runs both Node and Python kept the last value (role became admin). Other parsers may keep the first, raise an error, or retain both. That inconsistency is a real security concern when two components, such as a gateway and a backend, parse the same document and disagree on which duplicate wins. A schema validator operates on the parsed value, so it never sees the duplicate. If it matters, reject duplicates at the parser level, as shown in the Python example below.

Layer two from first principles: JSON Schema

A JSON Schema is a description of a set of acceptable JSON values. An empty schema {} accepts everything. Each keyword you add narrows the set. A validator takes an instance and a schema and reports whether the instance is in the set, and, ideally, why not.

Start with type

The type keyword restricts the kind of value. The allowed names are string, number, integer, boolean, object, array and null. You can give an array of types for unions:

{ "type": ["string", "null"] }

In my Ajv run, this schema accepted null and rejected 1. integer is not a separate JSON type in the grammar but a schema-level check on numbers. I verified that 1.0 passes {"type":"integer"} in Ajv while 1.5 does not, which matches the idea that the check is about the numeric value rather than its textual form. Other implementations may handle 1.0 differently, so test your own library.

Objects: properties, required, additionalProperties

For objects, three keywords do most of the work.

  • properties maps names to subschemas. It only constrains a property if that property is present.
  • required lists names that must be present. Listing a name in properties does not make it required, a surprise that causes many bugs.
  • additionalProperties controls names not listed in properties. Set it to false to forbid them, or to a schema to constrain them.
{
  "type": "object",
  "properties": { "name": { "type": "string" } },
  "required": ["name"],
  "additionalProperties": false
}

Another keyword, patternProperties, applies subschemas to names matching a regular expression, which is useful for maps with predictable key shapes.

Arrays: items, minItems, maxItems, uniqueItems

items describes the schema every element must satisfy. minItems and maxItems bound the length, and uniqueItems: true demands distinct elements. In the draft 2020-12 dialect, tuple-style validation moved to a separate keyword called prefixItems, while earlier drafts used items as an array for that purpose. This is one of the real differences between drafts, and one reason to declare $schema and to use a library that supports your chosen draft.

Strings and numbers

For strings, minLength and maxLength count characters (Unicode code points in the specification's terms, so an emoji counts as one, even though JavaScript's .length counts UTF-16 units). pattern applies a regular expression, and per the specification it is not implicitly anchored, so "pattern": "abc" matches xabcx. Anchor with ^ and $ when you mean a full match.

For numbers, minimum, maximum, exclusiveMinimum, exclusiveMaximum and multipleOf constrain range and granularity. In current drafts the exclusive keywords are numbers, while in the oldest drafts they were booleans modifying minimum. If you see "exclusiveMinimum": true in an old schema, it belongs to that older style, and a modern validator will reject it as a type error in the schema itself.

enum and const

enum lists the allowed values, which may be of any type, and const allows exactly one value. Use them for status codes, modes and discriminators. Both compare by deep equality.

format: a hint, sometimes a check

format names well-known string shapes such as email, date, date-time, uri and uuid. Here is the subtlety that trips up almost everyone: in current drafts format is by default an annotation, meaning validators are not required to reject non-matching values, and many do check it only when you opt in. In Ajv, formats come from a separate package, and I confirmed the failure mode directly. With plain Ajv and no format plugin, compiling {"type":"string","format":"email"} threw unknown format "email" ignored in schema at path "#". Setting strict: false made the compile succeed, and the schema then accepted the string "nope" as valid. Python's jsonschema only checks formats if you pass a FormatChecker, and some formats need optional extra packages installed. The lesson: never assume format is enforced. Turn it on explicitly and test with a bad value.

Combining schemas: allOf, anyOf, oneOf, not

These keywords build bigger rules out of smaller ones.

  • allOf: the instance must match every subschema.
  • anyOf: it must match at least one.
  • oneOf: it must match exactly one.
  • not: it must not match the subschema.

oneOf deserves care. If two branches can both match the same value, validation fails even though the value is arguably fine. I tested {"oneOf":[{"type":"number"},{"type":"number","minimum":0}]} in Ajv: the value 5 failed with must match exactly one schema in oneOf because both branches matched, while -1 passed because only the first did. When you only need "at least one", use anyOf.

Conditional rules: if, then, else

if, then and else express rules like "if the payment method is a card, a card number is required; otherwise an IBAN is". I ran this in Ajv:

{
  "type": "object",
  "required": ["method"],
  "properties": {
    "method": { "enum": ["card", "bank"] },
    "card": { "type": "string" },
    "iban": { "type": "string" }
  },
  "if": { "properties": { "method": { "const": "card" } } },
  "then": { "required": ["card"] },
  "else": { "required": ["iban"] }
}

The document {"method":"card","card":"4242"} passed. {"method":"card"} failed with must have required property 'card' and a second error, must match "then" schema. {"method":"bank"} failed with must have required property 'iban' plus must match "else" schema. Notice that each failure produces a pair of messages: the specific one from the branch and a summary from the if keyword. Read the specific one first.

Reuse with $defs and $ref

A $ref points to another schema by URI reference, and $defs is the conventional container for reusable subschemas inside one document. Local references such as #/$defs/item refer to a path in the same document. Older drafts used definitions for the same purpose, and many validators still accept both. References keep large schemas readable and ensure that "address" means the same thing everywhere.

Identity and the $schema keyword

$schema declares which draft the schema is written for, and $id gives the schema a base URI. A modern schema usually begins:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/order.json"
}

Which draft should you pick? Several exist, and I will not catalogue their differences from memory. Check what your validators support, prefer the newest draft that every tool in your pipeline understands, and read the changelog of the draft before migrating. Mixed drafts are the most common source of "works in my editor, fails in CI" behaviour. For instance, as noted above, tuple validation and the meaning of the exclusive bounds differ between drafts.

A realistic schema, run against real documents

Here is a complete schema for an order, using most of the keywords above. I saved it as schema.json.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/schemas/order.json",
  "title": "Order",
  "type": "object",
  "properties": {
    "id": { "type": "string", "pattern": "^ord_[0-9a-z]{8}$" },
    "email": { "type": "string", "format": "email" },
    "status": { "enum": ["pending", "paid", "shipped"] },
    "currency": { "const": "USD" },
    "items": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/item" }
    },
    "note": { "type": "string", "maxLength": 40 }
  },
  "required": ["id", "email", "status", "items"],
  "additionalProperties": false,
  "$defs": {
    "item": {
      "type": "object",
      "properties": {
        "sku": { "type": "string", "minLength": 3 },
        "qty": { "type": "integer", "minimum": 1, "maximum": 99 },
        "price": { "type": "number", "exclusiveMinimum": 0 }
      },
      "required": ["sku", "qty", "price"],
      "additionalProperties": false
    }
  }
}

A valid document:

{"id":"ord_a1b2c3d4","email":"ada@example.com","status":"paid","currency":"USD","items":[{"sku":"KB-101","qty":2,"price":49.5}]}

And an invalid one that breaks nearly every rule at once:

{"id":"order-1","email":"not-an-email","status":"refunded","currency":"EUR","items":[{"sku":"X","qty":0,"price":-5,"color":"red"}],"extra":true}

Validating in Node.js with Ajv

Ajv is a widely used JSON Schema validator for JavaScript. I installed ajv@8 and ajv-formats@3 with npm (the run reported Ajv 8.20.0) and used the 2020-12 build, since the schema declares that draft.

const Ajv = require("ajv/dist/2020");
const addFormats = require("ajv-formats");
const fs = require("fs");

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(JSON.parse(fs.readFileSync("schema.json")));

for (const f of ["valid.json", "invalid.json"]) {
  const ok = validate(JSON.parse(fs.readFileSync(f)));
  console.log(f, ok);
  if (!ok) {
    for (const e of validate.errors) {
      console.log(" ", e.instancePath || "(root)", e.keyword, "-", e.message, JSON.stringify(e.params));
    }
  }
}

The actual output:

valid.json true
invalid.json false
  (root) additionalProperties - must NOT have additional properties {"additionalProperty":"extra"}
  /id pattern - must match pattern "^ord_[0-9a-z]{8}$" {"pattern":"^ord_[0-9a-z]{8}$"}
  /email format - must match format "email" {"format":"email"}
  /status enum - must be equal to one of the allowed values {"allowedValues":["pending","paid","shipped"]}
  /currency const - must be equal to constant {"allowedValue":"USD"}
  /items/0 additionalProperties - must NOT have additional properties {"additionalProperty":"color"}
  /items/0/sku minLength - must NOT have fewer than 3 characters {"limit":3}
  /items/0/qty minimum - must be >= 1 {"comparison":">=","limit":1}
  /items/0/price exclusiveMinimum - must be > 0 {"comparison":">","limit":0}

Three details deserve attention. First, allErrors: true made Ajv collect all nine violations rather than stopping at the first. That is friendlier for users fixing a form, but it means more work per request, which matters for the denial-of-service discussion later. Second, instancePath is a JSON Pointer (RFC 6901 style) to the failing value, so /items/0/qty means the qty of the first item. Third, params carries machine-readable detail such as the limit or the offending property name, which you should use to build your own messages.

With the default allErrors: false, the validator stops at the first failure. I tested a tiny schema requiring an integer n against {"n":"x"} and got a single error with instancePath /n, keyword type and message must be integer.

Validating in Python with jsonschema

The Python jsonschema package installed cleanly with pip in my sandbox (version 4.26.0). The same schema and documents, using the 2020-12 validator class:

import json
from jsonschema import Draft202012Validator, FormatChecker

schema = json.load(open("schema.json"))
v = Draft202012Validator(schema, format_checker=FormatChecker())

for f in ["valid.json", "invalid.json"]:
    errs = sorted(v.iter_errors(json.load(open(f))), key=lambda e: list(e.path))
    print(f, "valid" if not errs else f"{len(errs)} errors")
    for e in errs:
        print(" ", "/".join(map(str, e.absolute_path)) or "(root)", "|", e.validator, "|", e.message)

The actual output:

valid.json valid
invalid.json 9 errors
  (root) | additionalProperties | Additional properties are not allowed ('extra' was unexpected)
  currency | const | 'USD' was expected
  email | format | 'not-an-email' is not a 'email'
  id | pattern | 'order-1' does not match '^ord_[0-9a-z]{8}$'
  items/0 | additionalProperties | Additional properties are not allowed ('color' was unexpected)
  items/0/price | exclusiveMinimum | -5 is less than or equal to the minimum of 0
  items/0/qty | minimum | 0 is less than the minimum of 1
  items/0/sku | minLength | 'X' is too short
  status | enum | 'refunded' is not one of ['pending', 'paid', 'shipped']

Both libraries find the same nine problems, which is the point of a standard: the contract is portable across languages. The message wording differs, and the Python validator iterates errors lazily with iter_errors, so you can stop early. The library also provides best_match, which picks the most relevant error from a set. For {"qty":"3"} against a schema wanting an integer qty, it returned '3' is not of type 'integer' with path ['qty'] and schema path ['properties', 'qty', 'type'].

Hardening the parse step in Python

Python's default json.loads accepts NaN and silently keeps the last duplicate key. Both can be tightened with hooks. I ran this:

import json

def reject_constant(c):
    raise ValueError(f"bad constant {c}")

def reject_duplicates(pairs):
    d = {}
    for k, val in pairs:
        if k in d:
            raise ValueError(f"duplicate key {k!r}")
        d[k] = val
    return d

try:
    json.loads('{"a":NaN}', parse_constant=reject_constant)
except ValueError as e:
    print(e)

try:
    json.loads('{"a":1,"a":2}', object_pairs_hook=reject_duplicates)
except ValueError as e:
    print(e)

It printed bad constant NaN and duplicate key 'a'. JavaScript's built-in JSON.parse has no equivalent hooks for duplicates, so if you need strict duplicate detection in Node, use a dedicated parser library that supports it, or validate at a gateway that does.

Reading and presenting error output

Raw validator output is for developers. Your users need something else. A few guidelines:

  • Use the path (/items/0/qty) to point at the field, and translate the keyword into a human sentence in your own wording. minimum with limit 1 becomes "Quantity must be at least 1".
  • Show the first error or a short list, not nine at once, unless the user is editing a form where every issue is actionable.
  • Never echo the full submitted value back if it might contain secrets such as passwords or tokens. Python's default messages include the offending value, as in 'order-1' does not match ..., which is convenient in development and risky in logs.
  • Distinguish the schema being wrong from the data being wrong. A compile-time error such as Ajv's strict mode: missing type "number" for keyword "minimum" is a bug in the schema, found when you load it, and should fail your build rather than reach a client.

Real-world use cases

API request validation. Check every incoming body before your handler runs. The schema doubles as living documentation. Frameworks in several ecosystems can wire a schema to a route so that invalid requests are rejected with a 400 before application code executes.

API response validation. Validating what you send is less common but valuable in tests. A contract test that runs your real handlers and checks their output against a schema catches the day someone renames a field. Consumers can validate responses from third-party APIs the same way, failing loudly when the provider changes shape without notice.

Configuration files. Application config, feature flags, build settings and infrastructure definitions are often JSON. Validating them at startup turns a typo like "timout": 30 into an immediate, clear failure instead of a mysterious default. Setting additionalProperties: false is what catches that typo, since an unknown key is the symptom.

CI pipelines. Validate checked-in JSON files (fixtures, translations, manifests) on every pull request. A CI step that parses and validates takes seconds and prevents a broken file from reaching production. Pair it with formatting so diffs stay readable, as described in our JSON Formatting Guide.

Webhooks and event streams. Messages from outside your trust boundary should be validated on receipt, and messages that fail should go to a dead-letter queue with the validation error attached, so someone can investigate without losing the payload.

Editor assistance. Many editors can use a JSON Schema to offer autocomplete and inline error squiggles for a given file pattern. This shifts validation to the moment of typing, which is the earliest possible point.

Data pipelines and ETL. Before loading JSON from an external source into a warehouse, validate it and quarantine bad records, rather than letting one malformed row abort an hour-long job.

Generating types and forms. Because a schema is machine-readable, tools can generate other artefacts from it. At a concept level, a code generator reads the schema and emits type definitions (TypeScript interfaces, Python dataclasses, Java classes) so the compiler and the runtime validator agree. Form builders can render input widgets from the same file. The direction matters: some teams write the schema and derive types, while others write types or models and export a schema. Either works if there is a single source of truth and a build step that keeps the other representation in sync. Generated types describe the shape at compile time, but they do not check runtime data, so you still validate at the boundary.

Validating with the DevUtilsX JSON Validator

When you need an answer now, without writing a script, the browser tool is the fastest route. The JSON Validator runs entirely in your browser. Your text is processed locally and nothing is uploaded, which matters when you are checking a config that contains internal hostnames or a payload with customer data.

  1. Open the JSON Validator.
  2. Paste your JSON into the input area, or type it directly.
  3. Run the validation. If the text is valid, you get a confirmation. If it is not, you get an error message that names the problem and its location, such as a position or a line and column.
  4. Go to the reported position and look at the character there and the one before it. Typical fixes are removing a trailing comma, replacing single quotes with double quotes, or quoting a bare key.
  5. Fix the first error and validate again. Parsers stop at the first problem, so a document with five mistakes needs several rounds. Work from the top.
  6. Once it validates, format it for readability with the formatting tools described in our JSON Formatting Guide, or compare it with the previous version using the approach in code diff and comparing files.

This tool covers layer one, the syntax check. For layer two, use a schema library in your code or CI as shown above. A useful routine is to clean the syntax in the browser first, because a schema validator cannot report anything useful about text that does not parse.

Common mistakes and pitfalls

Treating required as implied by properties. Listing a property does not make it mandatory. Add it to required.

Forgetting additionalProperties. Without it, unknown fields pass silently. That is sometimes right (see the design section) but should be a decision, not an accident. Be aware that additionalProperties: false only sees the sibling properties and patternProperties. If you combine it with allOf to compose schemas, properties declared in other branches count as additional, and valid data fails. Newer drafts offer unevaluatedProperties for this case, which is worth reading about when you compose schemas.

Assuming format is enforced. As shown above, email without a format plugin is not checked at all. Enable format checking and test with bad values.

Unanchored patterns. "pattern": "[0-9]+" accepts abc123def. Use ^[0-9]+$.

Using oneOf when you mean anyOf. Overlapping branches make valid data fail with must match exactly one schema in oneOf.

Mixing drafts. A schema written for one draft and loaded in a validator for another may behave differently, or silently ignore keywords. Always set $schema and use the matching validator class.

Validating the string instead of the parsed value. Validate objects, not text. And do not run validation on a value that failed to parse.

Ignoring numeric precision. Large integers lose precision before the schema ever sees them, as the 20-digit identifier example showed. If a number must be exact, model it as a string with a pattern.

Letting the schema and the code drift apart. A schema nobody enforces is a stale document. Run it in tests or CI, and fail the build when examples stop validating.

Hand-editing JSON that machines generate. Edits invite syntax errors. If a file is produced by code, change the generator.

Confusing null and absent. {"note": null} and {} are different. A schema with "note": {"type":"string"} rejects the null, and you need "type": ["string","null"] to allow it.

Schema design: strict, permissive, and versioned

Good schemas are decisions, not transcriptions of today's payload.

Strict or permissive?

Strict schemas (additionalProperties: false everywhere) catch typos and enforce contracts, and they are right for configuration files and for requests your own clients send. They are brittle for data you receive from others. If a partner adds a harmless new field and your strict validator rejects the whole message, you have turned a compatible change into an outage. A common approach, often called the tolerant reader principle, is to be strict about what you require and lenient about what you ignore, for data that crosses organisational boundaries. For outgoing responses, strict schemas in tests are useful because you control the producer.

Evolving a schema safely

Adding an optional property is usually compatible. Adding a required property, tightening a bound, narrowing an enum or changing a type breaks existing producers. Removing an enum value can break old data that you still store. Treat the schema as a public API:

  • Use $id URIs that include a version if you publish multiple versions side by side.
  • Keep a changelog and review schema changes like code changes.
  • Run old example documents against new schemas in CI to see what would break.
  • When you must make a breaking change, run both versions in parallel for a migration window.

Keep schemas readable

Use $defs for repeated shapes, add title and description keywords (annotations that help documentation and error messages), include examples, and keep each schema file focused. A thousand-line schema nobody understands will not be maintained.

Be explicit about empty and extreme values

Decide whether an empty string is acceptable and set minLength accordingly. Set maxLength on every free-text string and maxItems on arrays. Unbounded fields are both a design gap and a security gap.

Validation at API boundaries

Validation should happen where untrusted data enters your system, and again wherever a trust boundary is crossed.

On the server, always. Client-side validation improves user experience by giving instant feedback, but anyone can bypass it with a command-line tool. Treat client validation as a convenience and server validation as the real control. This is the most important security rule in this guide, and the one most often forgotten when a front-end team and a back-end team each assume the other is checking.

Validate before use. Parse, validate, and only then pass the data to business logic, database queries or file paths. Do not read fields from an unvalidated object, even to log them, if you can avoid it.

Return useful errors. A good response names the failing paths and keeps wording stable so clients can program against it. A sketch of a response body:

{
  "error": "validation_failed",
  "details": [
    { "path": "/items/0/qty", "message": "must be >= 1" },
    { "path": "/email", "message": "must be a valid email address" }
  ]
}

This is an illustrative shape, not a standard. Whatever shape you choose, keep it consistent across endpoints.

Validate in CI as well as at runtime. Runtime checks protect production from bad data. CI checks protect the repository from bad files. They overlap deliberately.

Compile schemas once. In Ajv, ajv.compile generates a validation function, so compile at startup and reuse the function. Compiling per request wastes time and memory. Python's jsonschema offers validator classes you instantiate once as well.

Security considerations

Validation is a security control, and it has its own failure modes.

Size and depth limits come first. A schema cannot protect you from a 500 MB body, because the parser has to read it before validation runs. Enforce a maximum request size at the web server or framework layer, and prefer parsers that let you cap nesting depth. Deeply nested arrays can exhaust the stack in some parsers. Use maxLength, maxItems and maxProperties to bound what the schema layer processes.

Beware ReDoS in pattern. The pattern keyword runs a regular expression on attacker-controlled input. Some patterns, particularly those with nested quantifiers like ^(a+)+$, take exponentially longer as the input grows when run by a backtracking engine. I measured this in Node on a string of a characters followed by b, using that pattern:

Input length (a's) Time measured
20 about 48 ms
24 about 136 ms
26 about 437 ms

These timings are from one run on a sandbox machine and will differ on yours, but the growth is the point: adding six characters multiplied the time by roughly nine. At 40 characters this pattern would effectively hang a request. Mitigations include avoiding nested quantifiers, bounding input with maxLength so that the worst case stays small, using a regex engine with linear-time guarantees where available, and being careful about patterns contributed by users rather than written by you.

Never accept untrusted schemas without care. If users can submit schemas, a malicious schema can use huge $ref chains, giant regexes or remote references to cause resource exhaustion or server-side requests. Disable remote reference resolution unless you need it, and limit schema size.

Do not leak internals in error messages. Validator messages can include submitted values and schema details. Return sanitised errors to clients and keep verbose output in protected logs.

Treat duplicates, prototype pollution and extra keys deliberately. Rejecting unknown properties with additionalProperties: false removes whole categories of mass-assignment bugs, where a client sets "isAdmin": true on a field you never meant to accept. In JavaScript, keys named __proto__ deserve attention when merging parsed objects into others, so prefer allow-listing known fields. Duplicate keys, covered earlier, can make two parsers disagree.

Schemas are not sanitisation. A string that matches pattern and maxLength is still untrusted text. Escape it for HTML, parameterise it for SQL, and handle it according to where it ends up. For the SQL side, our SQL formatting guide shows how readable queries make injection-prone string building easier to spot.

Validate on both ends. Validating your own outgoing data prevents leaking fields you did not intend to expose, since additionalProperties: false on a response schema fails a test when someone accidentally serialises an internal field.

Performance considerations

Validation costs CPU time, so measure before optimising.

  • Compile once, validate many. Libraries like Ajv compile a schema into a function, and that is where the speed comes from.
  • Prefer allErrors: false on hot paths. Stopping at the first error is cheaper and also limits the work an attacker can force. Use allErrors: true where the extra detail is valuable, such as interactive forms.
  • Keep schemas simple on hot paths. Deep oneOf/anyOf trees and heavy if/then logic make validation slower and errors noisier. A discriminator field with const on each branch lets you reason about the choice.
  • Limit what you validate. Validate at boundaries, not at every function call.
  • Mind the payload itself. Large documents cost time to parse before validation begins. See our guide on JSON minification and payload size for the techniques that shrink what you send, which also shrinks what the other side must validate.

Accessibility considerations

Validation errors are user interface, so they have accessibility requirements when shown in forms or editors.

  • Associate each error message with its field, for example with aria-describedby, so a screen reader announces the message when the field gains focus.
  • Do not rely on colour alone to mark an invalid field. Add text and an icon.
  • Announce a summary of errors with a live region or move focus to an error summary after submission, so keyboard and screen-reader users learn that something failed.
  • Write messages in plain language that says how to fix the problem ("Enter a quantity between 1 and 99"), instead of echoing a keyword like maximum.
  • For tools that report a position in a text area, also give line and column, and make the error reachable without a mouse.

Mapping instancePath values to form field IDs is the practical bridge: the schema tells you which field failed, and your form layer decides how to present it.

Troubleshooting: symptom, cause, fix

Symptom Likely cause Fix
Unexpected token at position 0 on a file that looks fine A byte order mark at the start of the file Re-save as UTF-8 without BOM, or strip U+FEFF before parsing; in Python open with utf-8-sig
Error points at } or ] A trailing comma before it Remove the comma on the previous line
Expected property name or '}' Single-quoted or unquoted key Use double quotes around every key and string
Bad escaped character Backslash in a string, such as a Windows path Double the backslash (\\) or use forward slashes
Bad control character in string literal Raw newline or tab inside a string Escape as \n or \t
Unexpected token 'N' or 'I' NaN or Infinity written as bare literals Use null or a string, and fix the producer
Unexpected non-whitespace character after JSON Two documents concatenated, or an extra closing brace Remove the extra text; for line-delimited data, parse each line separately
Unexpected end of JSON input Empty body or truncated download Check the response status and content length before parsing
Value changes after parsing (12345678901234567890 becomes 12345678901234567000) Number exceeds double precision Send large identifiers as strings
Duplicate key silently ignored Parser keeps the last (or first) value Reject duplicates in the parser or generate JSON from a map
must have required property 'x' Field missing or misspelled Check the sender; compare spelling and case
must NOT have additional properties Unknown field and additionalProperties: false Remove the field, or add it to the schema if it is legitimate
must match exactly one schema in oneOf Value matches multiple branches, or none Make branches mutually exclusive or use anyOf
unknown format "email" ignored in schema Format plugin not installed Install and register the formats package, or consciously relax strict mode
strict mode: missing type ... Keyword used without a type in strict compile Add the type next to minimum, pattern and similar keywords
Schema passes in the editor, fails in CI Different drafts or validator versions Pin $schema and library versions
format never rejects anything Format checking not enabled Enable the format checker and add a negative test
Validation is slow on certain inputs Backtracking regex in pattern Simplify the pattern and add maxLength
Valid documents rejected after composing with allOf additionalProperties: false in one branch blocks properties from others Restructure, or use unevaluatedProperties where your draft supports it

A general technique for stubborn cases is to bisect. Cut the document in half, validate each half, and keep narrowing until the error is isolated. For schemas, comment out keywords one at a time, or test each $defs entry separately with a minimal instance. And when you suspect a difference between environments, run the same file through two parsers, because disagreement is itself a diagnosis.

Best practices checklist

  • Parse first, validate second, and report the two kinds of failure differently.
  • Reject invalid syntax early, including NaN, Infinity and duplicate keys if your risk model requires it.
  • Declare $schema in every schema and use a validator that supports that draft.
  • Mark every mandatory field in required, and decide additionalProperties deliberately.
  • Add maxLength, maxItems and numeric bounds to anything attacker-controlled.
  • Anchor every pattern and review it for nested quantifiers.
  • Enable format checking explicitly and write a negative test for each format you rely on.
  • Compile schemas once at startup.
  • Enforce request size limits before parsing.
  • Validate on the server even when the client also validates.
  • Version schemas, review changes like code, and test old documents against new schemas.
  • Keep error messages for humans friendly and detailed logs for developers, without leaking secrets.
  • Use $defs for reuse and title, description and examples for documentation.
  • Run validation in CI for every checked-in JSON file that has a contract.
  • Send large integers and exact decimals as strings.
  • Treat generated types as a compile-time aid, not a replacement for runtime validation.

Comparison of alternatives

JSON Schema is not the only way to check structure. The right choice depends on your stack and on who must read the rules.

Approach Strengths Trade-offs
JSON Schema Language-neutral, large ecosystem, doubles as documentation, editor support Verbose, error messages vary, features differ between drafts
Hand-written checks No dependency, full control, tailored messages Easy to forget cases, drifts from documentation, hard to share between languages
Validation libraries in code (such as schema builders in TypeScript or model classes in Python) Types and validation in one place, good developer experience Tied to one language; you can often export a JSON Schema if you need to share
OpenAPI schema objects Describe whole APIs, reuse the schema vocabulary Dialect differences from plain JSON Schema depending on version; verify before sharing schemas
GraphQL types Enforced by the server, strongly typed queries Applies only to GraphQL APIs; see our GraphQL query formatting guide
Protocol Buffers and similar binary schemas Compact, strict, fast, code generation built in Not human-readable on the wire, needs tooling on both ends
XML Schema (XSD) Mature, rich typing for XML Applies to XML only; see our XML formatting guide
Syntax-only validator (browser tool or JSON.parse) Instant, no setup Says nothing about structure

Many teams combine them: a syntax check in the browser while debugging, a schema in CI, and a language-native library in the service, ideally all generated from or checked against one schema.

FAQ

What is the difference between JSON validation and JSON Schema validation?
Validation in the basic sense means checking that the text is syntactically valid JSON, which a parser does. JSON Schema validation goes further by checking the parsed value against rules about types, required fields and ranges. You need both, in that order.

Can JSON have comments?
Not in standard JSON as defined by RFC 8259. Parsers reject // and /* */. Some tools accept a superset format, but a file that relies on that will fail in strict parsers. Strip comments before parsing or use a format that supports them.

Why does my JSON fail with a position number but no line?
Some engines report only a character offset. Count from zero, and check the character at that index and the one before it. Pasting the text into the JSON Validator is the fastest way to find the spot, and many editors can jump to an offset.

Is a trailing comma ever allowed in JSON?
No. Neither arrays nor objects allow a comma after the last element in standard JSON. Remove it, or generate your JSON with a serialiser that never writes one.

Does JSON Schema check format values like email?
Not necessarily. In current drafts format is treated as an annotation by default, and validators decide whether to enforce it. In my tests Ajv refused to compile format: "email" without a format package, and Python's jsonschema needed an explicit FormatChecker. Always enable format checking deliberately and test it.

What is the difference between anyOf and oneOf?
anyOf passes if at least one subschema matches. oneOf passes only if exactly one does, so overlapping branches can cause valid data to fail. When in doubt, use anyOf, or make the branches mutually exclusive with a const discriminator.

Should I set additionalProperties to false?
For configuration files and requests from your own clients, usually yes, because it catches typos and blocks unexpected fields. For data from third parties that may add fields over time, leaving it open is often safer. Choose per schema and document the reason.

Which JSON Schema draft should I use?
Use the newest draft that all your tools support, declare it in $schema, and pin your validator version. Several drafts exist and they differ in some keywords, so check the documentation for the draft you pick before migrating existing schemas.

How do I validate large numbers exactly?
JSON numbers are often parsed into double-precision floats, which cannot represent every large integer. I saw 12345678901234567890 come back as 12345678901234567000 in Node. Send such values as strings, and constrain them with pattern.

Can validation prevent injection attacks?
It reduces the attack surface by rejecting unexpected shapes and sizes, but it does not sanitise content. A string that satisfies the schema still needs escaping or parameterisation wherever it is used.

Is client-side validation enough?
No. Anyone can send requests directly to your API, bypassing your front end. Validate on the server every time, and treat client checks as a user experience feature.

How do I generate types from a schema?
Use a code generator for your language that reads JSON Schema and emits type definitions, and run it as a build step so the types stay in sync. The generated types help at compile time, but runtime data still needs to be validated at the boundary.

Can I validate NDJSON or JSON Lines with a schema?
Validate each line separately. The file as a whole is not a single JSON document, so a parser given the entire file will report extra data after the first value.

Conclusion

JSON validation is two jobs, and each catches a different class of bug. A parser tells you whether the text is JSON, and the error messages, once you recognise trailing commas, single quotes, BOMs and raw control characters, usually point straight at the fix. A schema tells you whether the data is the right shape, and a few keywords (type, required, additionalProperties, enum, pattern, bounds, $ref) cover most real contracts. Put validation at every trust boundary, enforce it on the server, cap sizes and regex complexity, and keep schemas versioned and tested like code.

To check a document right now, paste it into the JSON Validator, which works locally in your browser. For related reading, see our JSON Formatting Guide for readable JSON and common syntax mistakes, and JSON minification and payload size for keeping the documents you validate small.

Further reading

Try the tools

Further reading

Related articles