Most teams treat JSON Schema as documentation that happens to be machine-readable. The teams that treat it as a validation gate catch a whole class of bugs in CI instead of in production.
There are two kinds of JSON Schema in the world, and they look almost identical. One describes your data — it's documentation with a machine-readable syntax, and nobody ever runs it. The other enforces your data — it's a gate that rejects malformed input with a useful error before it reaches your business logic. Most teams write the first kind, believe they've written the second, and are surprised when garbage flows straight through. The difference is a handful of keywords and one change in mindset.
Documentation schema vs validation schema
A documentation schema says "a user object has an email and an age." A validation schema says "a user object has exactly these fields, email must match this shape, age must be an integer in this range, and anything else is an error." JSON Schema is explicitly built for the second job. The current specification describes itself plainly:
"JSON Schema is a declarative language for defining structure and constraints for JSON data."
— json-schema.org, "What is JSON Schema?"
The word doing the work is "validate." A schema you only read is leaving its main feature switched off.
The additionalProperties trap
By default, JSON Schema allows extra properties. An object schema that describes email and age will happily accept a payload that also contains emial (misspelled), isAdmin: true (injected), and forty other fields — and validate it as correct, silently ignoring all of them. That typo'd field name is a real bug class: the client thinks it sent the value, the server accepted the request, and nothing was actually set. Adding "additionalProperties": false flips silent acceptance into an explicit rejection with a message that names the offending field. For any schema guarding an input boundary, this one line earns its place.
"format" doesn't validate by default
This one surprises people who thought they were safe. Writing "format": "email" does not, in most implementations, actually validate that the string is an email address. The format keyword is advisory by default — it annotates intent, and validators only enforce it if you explicitly enable format assertion. So a schema that "requires an email format" cheerfully accepts "not-an-email" unless you turned assertion on. If you rely on format for real validation, confirm your validator is configured to enforce it; otherwise it's documentation wearing a validator's clothes.
Composing with $ref and $defs
Real schemas repeat themselves — the same address shape, the same money type, over and over. $ref lets you define a shape once and reference it everywhere, and $defs is the modern place to keep those local definitions (it replaced the older definitions keyword). Composition keeps a large schema maintainable and consistent: fix the address definition once and every reference updates. The cost is a little indirection, and it's almost always worth paying past the first duplicated shape.
From schema to typed code
The highest-leverage use of a validation schema is generating your data models from it, so your runtime types and your validation layer can't drift apart. A single JSON Schema can produce Python Pydantic models, TypeScript interfaces, and Go structs. Now the schema is the one source of truth: change it, regenerate, and the type checker enforces the change across your codebase. This is the difference between validation as an afterthought and validation as the backbone of your data layer.
required is not the same as non-nullable
A field listed in required must be present in the object — but it can still be null unless you explicitly exclude null from its type. Writing "type": "string" and listing the field as required means it must exist and must be a string — but adding "type": ["string", "null"] allows null values through while still requiring the key to appear. These are three distinct states (absent, null, populated) and many schemas accidentally collapse them into two. For any field where the difference between "not sent" and "explicitly null" matters — which is most of them in a PATCH endpoint — spell out the intent in the schema. An absent field and a null field are different signals, and a schema that doesn't distinguish them will let one masquerade as the other.
Enum and const for fixed values
When a field should only accept specific values — a status that's one of "active", "paused", "archived" — use enum rather than validating with code after the schema passes. An enum constraint rejects unknown values at the schema layer with a message naming the offending value, which is far more useful than a downstream ValueError that may or may not include context. For fields with a single fixed value (a type discriminator in a tagged union), const is even more precise. The habit of treating the schema as "shape only" and pushing value validation into application code wastes the schema's strongest feature: catching bad values before your code ever sees them.
Versioning and backwards compatibility
Schemas evolve, and the breaking changes are specific and predictable. Adding a new optional field is always safe. Adding a new required field breaks every existing producer. Narrowing a type (string to enum, number to integer) breaks payloads that were valid under the old schema. Removing additionalProperties: false is safe; adding it to a schema that didn't have it is a breaking change for any producer sending extra fields. Widening is safe; narrowing is breaking. Know which direction your change goes, and version the schema explicitly (a $id with a version path, or a version field in the payload itself) so consumers can migrate rather than discover the break in production. A schema without a version is a schema that can never safely change.
The workflow
Start by getting the schema itself clean and legible — a JSON formatter makes a dense nested schema readable so you can actually see whether additionalProperties and required are set where they need to be. Turn the validated shape into a typed model with the JSON Schema to Pydantic tool so your Python code and your schema stay in lockstep. And as the schema evolves across versions, run old against new in a JSON diff to catch breaking changes — a field that became required, a type that narrowed — before they reach the clients depending on you. A schema that's read is documentation. A schema that's run, typed, and diffed is infrastructure.