JSON Schema Validation Explained: How to Catch Bad Data Before It Breaks Your API

A JSON body can parse cleanly and still be garbage. JSON.parse tells you the brackets balance and the quotes close. It says nothing about whether quantity is negative, email is missing, or status is the string "shipped" when your code only handles "SHIPPED". Syntax is the floor, not the bar.

JSON Schema is how you raise the bar: a declarative contract that says what shape the data must have, plus a validator that checks a document against it and hands back a precise list of what failed and where. The trouble is that "validate against JSON Schema" hides three things that bite people in production: which draft you are actually running, how error paths are expressed, and whether the format keyword does anything at all. This guide walks all three, with a browser-based JSON Schema validator you can paste into as you read.

Valid syntax is not valid data

Here is the distinction that matters. Take this payload:

{ "id": 42, "email": "not-an-email", "age": -5 }

It is perfectly valid JSON. Every parser accepts it. But if your API expects a real email and a non-negative age, this is bad data wearing a valid-syntax disguise. It sails through JSON.parse, hits your business logic, and fails somewhere downstream where the stack trace points at the wrong line.

A schema turns the implicit rules in your head into something a machine enforces:

{
  "type": "object",
  "properties": {
    "id":    { "type": "integer" },
    "email": { "type": "string", "format": "email" },
    "age":   { "type": "integer", "minimum": 0, "maximum": 150 }
  },
  "required": ["id", "email"]
}

Validate the payload against this schema and you stop guessing. You get a list: age is below the minimum, and (depending on your validator, more on that below) email is malformed. That list is the whole point. It is the difference between "the request looked wrong" and "/age must be >= 0."

The practical place this earns its keep is at boundaries you do not control: third-party webhooks, partner APIs, and increasingly, structured output from an LLM. When a model returns JSON that is supposed to match a declared schema, validating it is the only way to know the model actually obeyed before you write the row to your database.

Draft 2020-12 vs draft-07: the version that silently changes behavior

JSON Schema is versioned by draft. The two you meet in the wild are draft-07 (old, ubiquitous, what a huge amount of existing tooling defaults to) and draft 2020-12 (current, what OpenAPI 3.1 aligns with). They are not interchangeable, and the gaps are exactly where silent bugs live.

The keyword renames and additions that actually change validation:

  • definitions became $defs (the rename landed in draft 2019-09 and carries through 2020-12). A draft-07 schema referencing #/definitions/Foo does not line up with a 2020-12 schema that puts its subschemas under $defs.
  • Tuple validation changed shape. Draft-07 used items as an array plus additionalItems. Draft 2020-12 splits this into prefixItems (the positional schemas) and items (everything after). Same intent, different keywords.
  • dependencies split into dependentRequired and dependentSchemas.
  • 2020-12 carries unevaluatedProperties and unevaluatedItems (both introduced in 2019-09), which let you do strict-ish validation across combined schemas in a way draft-07 simply cannot express.
  • The $schema URI itself differs: http://json-schema.org/draft-07/schema# versus https://json-schema.org/draft/2020-12/schema.

The takeaway is not the list. It is this: a validator built for one draft can mis-handle a schema written for another, and it does not always error loudly. Declare "$schema": "https://json-schema.org/draft/2020-12/schema" to a validator that only carries the draft-07 meta-schema and it can refuse to compile, complaining it has no schema for that reference. Drop the $schema line but keep a 2020-12 keyword like prefixItems, and depending on the engine it may quietly do the right thing, the wrong thing, or ignore the keyword entirely. The dangerous case is the quiet one: a tuple constraint that never runs, so bad arrays pass. If you assume "it's JSON Schema, it'll just work," you will eventually ship the version mismatch.

Before you trust a validator with a 2020-12 schema, confirm it speaks 2020-12. Match the $schema you declare to the dialect the tool advertises.

JSON Pointer: how good validators tell you where it broke

A validator that just says "invalid" is almost useless on a nested payload. The useful signal is the location of each failure, and the standard way to express location is a JSON Pointer (RFC 6901).

JSON Pointer is a slash-delimited path into the document. /users/0/email means: the email field of the first element of the users array. The root document is the empty string. Array indices are zero-based integers. The two escapes you need to know: ~1 stands for a literal / in a key, and ~0 stands for a literal ~.

This is the same notation across the ecosystem, which is what makes it valuable. The validator on codeswap reports each violation as a row of path, keyword, and message, where the path is the JSON Pointer to the offending value. For the payload above, the minimum violation surfaces at /age with keyword minimum. You can take that pointer and resolve it directly against the document to see the bad value in context, which is what the JSON Pointer resolver is for. Schema location and instance location both being pointers lets you jump from "this rule failed" to "this exact field" without eyeballing line numbers.

One detail worth internalizing: the path points at the data, not at your schema. /age is where in the payload the problem is. The schema path (where in the schema the failing rule lives) is separate. When you are debugging, you usually want the instance path first, because that is the value you have to fix or reject.

The format trap: why your email validation does nothing

This is the one that catches experienced people, because it looks like it should obviously work. You write "format": "email", you feed in "not-an-email", and the validator reports no email error. The schema looks correct. The data is clearly wrong. Nothing fires.

The reason is in the spec. In JSON Schema, format is an annotation by default, not an assertion. In draft 2020-12 this is formalized: the format-annotation vocabulary describes the data without enforcing it, and actual enforcement lives in a separate format-assertion vocabulary that a validator must opt into. Support for the annotation vocabulary is required of validators; support for the assertion vocabulary is optional. The intent was deliberate, because some formats are genuinely hard or ambiguous to check, and the spec did not want every validator silently disagreeing about what counts as a valid email or URI.

The downstream consequence: whether format is checked at all depends on the validator and its configuration. Ajv, the most common JavaScript validator, ships with format doing nothing unless you also install and register the ajv-formats package. The codeswap validator runs Ajv without that add-on, which means it behaves exactly like the default: paste the email schema and the malformed payload, and the only violation it reports is /age being below minimum. The bad email passes, because format is being treated as documentation, not a rule.

That is not a bug in the tool. It is the spec's default behavior made visible, and it mirrors what your production validator probably does unless someone wired up formats on purpose. The lesson is blunt: do not rely on format to validate anything you actually care about. If an email or UUID or date being well-formed matters, enforce it explicitly. Use pattern with a regex for the cases a regex can express, constrain with enum where the set is finite, and treat format as a hint to humans reading the schema rather than a guardrail. If you do want format enforced, that is a conscious choice you make in your own validator setup, and you should test that it actually fires before trusting it.

Running a validation: the workflow

The mechanics are deliberately boring, which is how it should be. In the JSON Schema validator:

  • Paste the schema in the left pane and the document to check in the right pane.
  • Click Validate. A clean pass says the data matches the schema. A failure returns a table of every violation at once.
  • Read each row as path, keyword, value. The path is your JSON Pointer to the bad field; the keyword tells you which constraint failed (required, minimum, type, and so on); the message is the human-readable why.

It reports all errors, not just the first. That matters more than it sounds. A validator that stops at the first failure makes you fix-and-rerun in a slow loop. Getting every violation in one pass means you see the full damage and fix it in one edit.

Two practical notes. First, both panes must be syntactically valid JSON before validation can run, so if you are starting from a hand-edited or model-produced blob, clean it up first; the JSON formatter will pretty-print and surface parse errors. Second, validation is conformance checking, not syntax checking. If your question is instead "is this text strict, spec-clean JSON" (no trailing commas, no comments, no NaN), that is a different job, and the strict JSON validator is the tool for it.

Where validation belongs in your pipeline

Validation pays off when it sits at a boundary and rejects bad data before it propagates. A few patterns that hold up:

  • Inbound API requests. Validate the request body against a schema at the edge and reject with a clear error pointing at the offending field. The client gets /items/0/quantity must be >= 1 instead of a generic 500 ten layers deep.
  • Third-party responses and webhooks. You do not control what a partner sends. Validate their payload before you trust it, so a contract change on their end surfaces as a clean validation failure rather than a corrupted record.
  • LLM structured output. When you ask a model for JSON matching a schema, validate the result. Models drift, hallucinate fields, and occasionally return prose. The schema is your gate.
  • CI fixtures and contract tests. Keep the schema in version control and validate example payloads against it in CI, so a schema change that breaks an existing fixture fails the build.

If you are working the other direction, generating a schema from data you already have, the JSON Schema generator infers one from a sample document, and the schema to example generator produces conforming sample data for docs and tests. The pair gets you from "I have JSON" to "I have a tested contract" without writing the schema by hand.

The mental model to keep: parsing proves the bytes are JSON. Validation proves the JSON is yours. Knowing which draft you run, reading the JSON Pointer paths, and not trusting format to do work it was never guaranteed to do are the three things that separate validation that protects you from validation that gives you false confidence.

Use the tool

Skip the manual work. The companion tool runs this in your browser, with nothing uploaded.

JSON Schema Validator

Frequently asked questions

What's the difference between validating JSON syntax and validating against a schema?

Syntax validation (what JSON.parse does) only confirms the text is well-formed JSON: balanced brackets, quoted keys, valid escapes. Schema validation goes further and checks the data's shape and constraints: required fields are present, numbers are in range, types match, enums hold. A document can pass syntax and fail schema validation, which is the common case for bad API data.

Should I use draft 2020-12 or draft-07?

Use draft 2020-12 for new work; it's the current spec and aligns with OpenAPI 3.1. Draft-07 is still everywhere in older tooling, so you'll encounter it when working with existing schemas. The critical thing is consistency: make sure your validator supports the draft your schema declares in $schema, because a validator built for one draft can mis-handle or refuse a schema written for the other.

Why isn't my `format: "email"` constraint catching invalid emails?

Because format is an annotation by default in JSON Schema, not an assertion. Many validators, including Ajv in its default configuration, treat format as documentation and don't enforce it unless you explicitly opt in (for Ajv, by adding the ajv-formats package). If you need an email, UUID, or date to actually be checked, enforce it with a pattern regex or enum, or deliberately enable format assertion in your validator and test that it fires.

What is the JSON Pointer in a validation error?

It's a slash-delimited path (RFC 6901) pointing to the exact value in your data that failed a rule. For example, /users/0/email is the email field of the first users element. Array indices are zero-based, the root is the empty string, and ~1 escapes a literal slash inside a key. The same notation is used across validators, so you can take the pointer and resolve it directly against the document to find the bad value.

Can JSON Schema validation check structured output from an LLM?

Yes, and it's one of the strongest use cases. When you ask a model for JSON matching a declared schema, validate the returned document against that schema before using it. Models occasionally omit fields, invent extra ones, or return prose instead of JSON, and validation turns those failures into a precise error list instead of a downstream crash.

Does the validator report all errors or just the first one?

It reports all violations in a single pass, each as a row of JSON Pointer path, failing keyword, and message. Getting the full list at once is faster than a fix-one-rerun loop, because you see the complete set of problems and can correct them in a single edit.

Related tools