A mobile client starts sending "age": "thirty-two", and three services later something crashes with an error nobody can trace. JSON Schema lets you catch that at the front door, with a message that says exactly which field is wrong.
What is JSON Schema?
JSON Schema is a way to describe what valid JSON looks like: which fields must be present, what types they have, which values are allowed and how nested objects and arrays are shaped. It does for JSON data roughly what TypeScript types do for JavaScript, except the checks run while your program is running.
Schemas are written in JSON too, so people and programs can both read them. A validator takes a JSON document and a schema, then either says the document is valid or hands you a list of errors. You'll find it behind APIs, config files, forms and data pipelines.
The specification has gone through several versions, called drafts. Draft-07 is the most widely supported. Drafts 2019-09 and 2020-12 add features such as prefixItems, $defs and dynamic references, but draft-07 is still the safest choice if a schema has to work across many languages and tools.
Core schema keywords
type
type restricts a value to a data type. There are seven: string, number, integer, boolean, null, object and array. An array of types allows any of them.
{ "type": "string" }{ "type": "integer" }{ "type": ["string", "null"] }properties and required
For objects, properties lists the expected keys and a schema for each value. required lists the keys that must be present. Anything not in required is optional, which surprises people: listing a property doesn't make it mandatory.
{
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"age": { "type": "integer", "minimum": 0 }
},
"required": ["name", "email"]
}Here name and email are required strings and age is an optional non-negative integer. {"name": "Mei", "email": "mei@example.com"} is valid. {"name": "Mei"} fails because email is missing. And "age": "thirty-two" fails because it's a string, which is exactly the bug from the start of this post.
items for arrays
items gives the schema every array element must match. minItems and maxItems limit the length, and uniqueItems requires all elements to be different.
{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"tag": { "type": "string" }
},
"required": ["id", "tag"]
},
"minItems": 1,
"uniqueItems": true
}This accepts a non-empty array of tag objects, each with an integer id and a string tag, and no two elements identical.
enum and const
enum limits a value to a fixed list. const limits it to exactly one value. Both suit status fields and settings that must come from a known set.
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending", "active", "suspended", "deleted"]
},
"version": {
"const": "1.0"
}
}
}String keywords
minLengthandmaxLengthlimit the length.patternchecks the string against a regular expression. The pattern is not anchored, so add^and$to match the whole string.formatchecks common formats such asemail,uri,date-time,ipv4anduuid. Many validators only enforce formats when you turn that on.
{
"type": "string",
"minLength": 8,
"maxLength": 128,
"pattern": "^(?=.*[A-Z])(?=.*[0-9])"
}A complete example: validating an API request
Here's a schema for the body of a POST /api/users sign-up request. The server checks it before doing anything else, so bad input never reaches the database:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserRegistration",
"description": "Body of the POST /api/users registration endpoint",
"type": "object",
"properties": {
"username": {
"type": "string",
"minLength": 3,
"maxLength": 30,
"pattern": "^[a-zA-Z0-9_-]+$",
"description": "Letters, digits, underscores and hyphens"
},
"email": {
"type": "string",
"format": "email"
},
"password": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"plan": {
"type": "string",
"enum": ["free", "pro", "enterprise"],
"default": "free"
},
"tags": {
"type": "array",
"items": { "type": "string", "maxLength": 50 },
"maxItems": 10,
"uniqueItems": true,
"default": []
},
"profile": {
"type": "object",
"properties": {
"displayName": { "type": "string", "maxLength": 100 },
"bio": { "type": "string", "maxLength": 500 },
"website": { "type": "string", "format": "uri" }
},
"additionalProperties": false
}
},
"required": ["username", "email", "password"],
"additionalProperties": false
}This document passes every constraint:
{
"username": "alice_dev",
"email": "alice@example.com",
"password": "secureP4ss!",
"plan": "pro",
"tags": ["developer", "react"],
"profile": {
"displayName": "Alice",
"bio": "Full-stack developer",
"website": "https://alice.dev"
}
}This one manages to break six rules at once, which is impressive in its own way:
{
"username": "al",
"email": "not-an-email",
"password": "short",
"plan": "premium",
"tags": ["developer", "developer"],
"extraField": true
}(root): must NOT have additional properties
username: must NOT have fewer than 3 characters
email: must match format "email"
password: must NOT have fewer than 8 characters
plan: must be equal to one of the allowed values
tags: must NOT have duplicate items (items ## 1 and 0 are identical)These are the messages the PulpMiner validator shows. Notice that the additionalProperties error is reported on the object that contains the extra key, which here is the top level, shown as (root).
Using JSON Schema in your application
Every major language has a JSON Schema library, so you can run the same schema in your API and your tests.
JavaScript and TypeScript with Ajv
Ajv compiles a schema into a validation function, which makes it one of the fastest validators for JavaScript. Format checks such as email come from the separate ajv-formats package.
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true });
addFormats(ajv); // adds "email", "uri", "date-time" and more
const schema = {
type: "object",
properties: {
username: { type: "string", minLength: 3 },
email: { type: "string", format: "email" },
},
required: ["username", "email"],
};
const validate = ajv.compile(schema);
const data = { username: "al", email: "bad" };
if (!validate(data)) {
console.log(validate.errors);
// [
// { keyword: "minLength", instancePath: "/username", ... },
// { keyword: "format", instancePath: "/email", ... }
// ]
}Without allErrors: true, Ajv stops at the first error, and your users fix their form one field at a time.
Python with jsonschema
jsonschema is the standard Python library. Heads up: it ignores format unless you pass a format checker:
import jsonschema
schema = {
"type": "object",
"properties": {
"username": {"type": "string", "minLength": 3},
"email": {"type": "string", "format": "email"},
},
"required": ["username", "email"],
}
data = {"username": "al", "email": "bad"}
validator = jsonschema.Draft7Validator(
schema, format_checker=jsonschema.FormatChecker()
)
for error in validator.iter_errors(data):
print(f"{error.json_path}: {error.message}")
# $.username: 'al' is too short
# $.email: 'bad' is not a 'email'Combining and reusing schemas
Once a schema grows past a few fields, you'll want to reuse pieces and express rules like “this or that”. These keywords do that:
- allOf: the data must match every subschema. Use it to combine constraints or extend a base schema.
- anyOf: the data must match at least one subschema, like a union type.
- oneOf: the data must match exactly one subschema. Useful when variants must not overlap.
- not: the data must not match the subschema.
- if, then, else: apply different rules depending on whether the data matches a condition, for example requiring a five-digit ZIP code only when the country is
"US". - $ref: points to another schema, so you can define a type once (in draft-07, usually under
definitions) and reuse it. - additionalProperties: set to
falseto reject any key not listed inproperties, which is what you usually want for API input.
This schema uses $ref for a shared address type, and if/then/else to require a billing address only when sameAsShipping is false:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"country": { "type": "string" }
},
"required": ["street", "city", "country"]
}
},
"type": "object",
"properties": {
"shippingAddress": { "$ref": "#/definitions/address" },
"billingAddress": { "$ref": "#/definitions/address" },
"sameAsShipping": { "type": "boolean" }
},
"required": ["sameAsShipping"],
"if": {
"properties": { "sameAsShipping": { "const": false } }
},
"then": {
"required": ["shippingAddress", "billingAddress"]
},
"else": {
"required": ["shippingAddress"]
}
}sameAsShipping is required here on purpose. An if that only uses properties also passes when the key is missing. Without the required line, an object that left the field out would quietly get the then rules. It's the kind of bug that takes an afternoon to find.
How to use the JSON Schema validator
- Open the JSON Schema validator. Paste the document to check into JSON data on the left and your schema into JSON Schema on the right. To try it first, load one of the examples, such as Nested + $ref or oneOf / anyOf.
- Select Validate. If either box is not valid JSON, or the schema cannot be compiled, you see that error first.
- Read the result. A valid document gets a confirmation. Otherwise you get every error with its path in dot notation, such as
home.zip, or(root)for the top level. - Fix the schema or the data and validate again. Copy copies the results as plain text.
The validator uses Ajv with format checks turned on and supports draft-07. A schema whose $schema points to draft 2019-09 or 2020-12 won't compile, so remove that line or stick to draft-07 keywords. Validation runs in your browser.
Best practices
- Start small. Begin with the required fields and add constraints as you need them. An overly strict schema breaks more often as the API changes.
- Use
additionalProperties: falsefor API input. It catches misspelled field names and keeps unexpected data out. - Describe your fields. A schema with
descriptionon each field doubles as documentation. - Reuse types with
$ref. Define addresses, pagination parameters and error responses once. - Version your schemas. Keep them in version control next to the code and note which schema version matches which API version.
- Test the edges. Try empty objects,
null, empty arrays, very long strings and negative numbers. Schema bugs usually hide at the boundaries.
