JSON Schema Explained: Stop Guessing If Your API Data Is Valid
Last year I ran an internal API that accepted JSON from twelve different teams. Every team had their own idea of what a "valid" payload looked like. One team sent age as a string. Another omitted email entirely. A third nested address three levels deeper than anyone else. I spent three full sprints writing defensive if statements, chasing down edge cases one ticket at a time. Then I switched to JSON Schema and deleted roughly 80% of that validation code in a single afternoon.
JSON Schema is not exciting. It is paperwork for your data. But it is the kind of paperwork that stops you from debugging malformed payloads at 2 AM on a Saturday. You write a schema - itself a JSON document - that describes what your data should look like, and a validator tells you whether the incoming payload matches. That's it. No magic.
What JSON Schema Actually Is
JSON Schema is a vocabulary for describing the structure of JSON documents. You define what properties an object should have, what types those properties should be, which fields are required, and what constraints the values must satisfy. Think of it as TypeScript's type system, but for raw JSON data at runtime. The schema itself is just JSON, so you can store it, version it, and share it like any other configuration file.
Here is the smallest useful schema you can write:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name"]
}The $schema keyword declares which draft of JSON Schema you're using. type says the root value must be an object. properties defines the expected fields and their types. requiredlists which properties must be present. Any data that matches these rules passes validation. Anything that doesn't gets rejected with a clear error message.
Defining Types
JSON Schema supports seven type keywords, matching JSON's own value types plus one addition:
"string"- text values"number"- any numeric value, integer or float"integer"- whole numbers only (a JSON Schema addition, not a native JSON type)"boolean"-trueorfalse"object"- key-value pairs wrapped in curly braces"array"- ordered lists wrapped in square brackets"null"- the null value
You can allow multiple types by passing an array:
{ "type": ["string", "null"] }This is equivalent to a nullable string - the value can be either a string or null. This pattern comes up constantly in APIs where a field is optional but, when present, must be a specific type.
String and Number Constraints
Types alone are rarely enough. You almost always need to constrain values further. For strings, JSON Schema provides minLength, maxLength, and pattern:
{
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^[a-zA-Z0-9_]+$"
}The pattern keyword takes a regular expression. The one above requires the string to contain only alphanumeric characters and underscores - useful for usernames or slugs.
For numbers, you get minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf:
{
"type": "number",
"minimum": 0,
"maximum": 10000,
"multipleOf": 0.01
}The multipleOf keyword is surprisingly useful. Currency values almost always need "multipleOf": 0.01 to enforce two decimal places. Without it, a client could send 19.999 and your billing logic would silently round in unexpected ways.
Enums and Arrays
When a value must come from a fixed set, use enum:
{
"type": "string",
"enum": ["draft", "published", "archived"]
}enum works with any type, not just strings. You can have an enum of numbers, booleans, or even mixed types if you omit the type keyword.
Arrays get their own set of constraints through items, minItems, maxItems, and uniqueItems:
{
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1,
"maxItems": 20,
"uniqueItems": true
}This defines an array of 1 to 20 unique, non-empty strings. The items keyword can be any schema, including a complex object - so you can validate arrays of objects with specific shapes.
Nested Objects and $ref
Real-world schemas rarely stay flat. Objects contain objects, and the same structure often appears in multiple places. The $defs keyword (called definitions in older drafts) lets you define reusable sub-schemas, and $ref lets you reference them:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"country": { "type": "string" }
},
"required": ["street", "city", "country"]
}
},
"type": "object",
"properties": {
"billingAddress": { "$ref": "#/$defs/address" },
"shippingAddress": { "$ref": "#/$defs/address" }
}
}Both billingAddress and shippingAddress share the same shape without duplicating the definition. The $ref keyword uses JSON Pointer syntax to point at the reusable schema. This keeps your schemas DRY and makes them far easier to maintain as your API grows.
A Real-World Example
Here's a schema for a product in an e-commerce API, putting together everything covered above:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string", "minLength": 1, "maxLength": 200 },
"price": { "type": "number", "minimum": 0, "multipleOf": 0.01 },
"currency": { "type": "string", "enum": ["USD", "EUR", "GBP"] },
"status": { "type": "string", "enum": ["draft", "active", "discontinued"] },
"tags": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"maxItems": 20,
"uniqueItems": true
},
"createdAt": { "type": "string", "format": "date-time" }
},
"required": ["id", "name", "price", "currency", "status"],
"additionalProperties": false
}The additionalProperties: false at the end is worth calling out. It rejects any properties not explicitly listed in the schema. A request with nane instead of name gets rejected rather than silently ignored. This single schema replaces dozens of lines of imperative validation code, and it doubles as living documentation - anyone reading it understands exactly what a valid product looks like.
Validation in Practice
A schema is just a document until you run it through a validator. In JavaScript, ajv is the fastest and most popular option:
import Ajv from "ajv";
const ajv = new Ajv();
const schema = {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 0 }
},
required: ["name", "email"],
additionalProperties: false
};
const validate = ajv.compile(schema);
const data = { name: "Dana", email: "dana@example.com", age: 30 };
const valid = validate(data);
if (!valid) {
console.log(validate.errors);
// Each error includes the path, keyword, and a human-readable message
}You compile the schema once and reuse the validate function on every incoming request. The errors array tells you exactly which field failed and why, so you can return meaningful 400 responses instead of cryptic server errors.
Getting Started
If you haven't used JSON Schema before, start small. Pick one endpoint. Write a schema for its request body. Wire up validation in development, watch it catch bugs you didn't know existed, and then promote it to production. Once you see how many issues it catches before they reach your business logic, you'll want schemas everywhere.
Before writing your first schema, make sure the JSON you're working with is actually valid. Our guide to the most common JSON errors covers the syntax mistakes that trip up even experienced developers. And for a deeper dive into JSON handling in code, see working with JSON in JavaScript. You can also paste your JSON into our formatter to clean it up before you start writing constraints around it.
Clean Up Your JSON First
Before you write your first schema, make sure your base JSON is valid. Use our formatter to clean up your structure.
Open JSON Prettifier