JSON Schema Validation Guide: Draft Versions, Keywords, and CI Integration
JSON Schema is the standard way to describe the shape of JSON data — what fields exist, which are required, what types they hold, and what constraints they satisfy. Use it for API contracts, configuration file validation, code generation, and automated testing. Follow along with the JSON Schema Validator to validate schemas and payloads as you read.
Why Use JSON Schema
A schema serves four distinct purposes simultaneously, which is why it has become the backbone of modern API tooling:
- Runtime validation: reject malformed requests before they touch business logic. A single schema check at the API boundary eliminates entire classes of null pointer errors and unexpected type coercions.
- Documentation: a schema is machine-readable documentation. Tools like Swagger UI and Redoc render it as interactive API references automatically.
- Type generation: tools such as
json-schema-to-typescript, QuickType, and Pydantic can generate typed models directly from a schema, keeping runtime validation and static types in sync. See also the JSON to TypeScript guide. - API contracts: schemas form the contract between producer and consumer. Publishing a schema and validating against it in CI means breaking changes are caught before they reach production.
A Minimal Schema Example
Before diving into draft versions and advanced keywords, here is a working schema that describes a user object:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] }
},
"required": ["id", "name", "email"],
"additionalProperties": false
}This schema accepts an object with id, name, and email as required fields, an optional role constrained to three values, and rejects any property not listed. Copy it into the JSON Schema Validator to see exactly which payloads pass and which fail.
Draft Versions
JSON Schema has evolved through several published drafts. The version is declared with $schema. Knowing which draft your tooling implements prevents subtle validation surprises.
| Draft | Year | Status | Notable changes |
|---|---|---|---|
| draft-04 | 2013 | Legacy | First widely adopted version; introduced $ref |
| draft-06 | 2017 | Legacy | Added $id, contains, const, propertyNames |
| draft-07 | 2018 | Most used | Added if/then/else, readOnly, writeOnly, $comment |
| 2019-09 | 2019 | Transitional | Renamed definitions to $defs; $recursiveRef; unevaluatedProperties |
| 2020-12 | 2020 | Latest | prefixItems replaces tuple-form items; $dynamicRef; OAS 3.1 compatible |
Draft-07 remains the most widely supported across validators and code generators. Use 2020-12 if you are writing OpenAPI 3.1 specs or if your toolchain explicitly supports it. The most visible breaking change between drafts is array validation: in draft-07, a tuple is "items": [{...}, {...}] (an array of schemas); in 2020-12 that becomes "prefixItems", and "items" now only applies to additional items.
Core Keywords
These keywords appear in almost every real-world schema. Understanding each one precisely prevents validation surprises.
type— restricts the JSON type:"string","number","integer","boolean","object","array","null". A value can match multiple types:"type": ["string", "null"]is a nullable string.properties— defines schemas for named object properties. A property listed here is not required unless also inrequired.required— array of property names that must be present. An empty object{}satisfiesrequired: [].additionalProperties— whenfalse, any property not inpropertiesorpatternPropertiescauses validation to fail. When a schema object, additional properties must match that schema.patternProperties— likepropertiesbut keys are regex patterns. Useful for maps with dynamic keys:"^[a-z]+$": { "type": "number" }.enum— restricts the value to one of a fixed list."enum": ["active", "inactive"]rejects anything else.const— restricts the value to exactly one literal."const": "v2"is equivalent to"enum": ["v2"].
Array Validation
Arrays have their own set of keywords. In draft-07, array validation looks like this:
{
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
"contains": { "const": "admin" }
}items(draft-07) — schema that every element must satisfy. In 2020-12 this keyword only applies to items after anyprefixItems.prefixItems(2020-12) — array of schemas for positional tuple elements.contains— at least one element must match. Combined withminContainsandmaxContains(2019-09+) for range constraints.minItems/maxItems— length bounds.uniqueItems— whentrue, all elements must be distinct. Deep equality is used for objects.
$ref and $defs
Real schemas quickly develop repeated sub-schemas — an address object used in billing, shipping, and contact fields. $defs (called definitions in draft-07) lets you define reusable schemas in one place and reference them with $ref.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"Address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string", "pattern": "^[0-9]{5}$" }
},
"required": ["street", "city", "zip"]
}
},
"type": "object",
"properties": {
"billing": { "$ref": "#/$defs/Address" },
"shipping": { "$ref": "#/$defs/Address" }
}
}$ref accepts a JSON Pointer within the same document (#/$defs/Name) or a URL to an external schema file ("$ref": "./address.schema.json"). For recursive structures such as a tree node that contains child nodes of the same type, the self-referencing $ref is the only practical option:
{
"$defs": {
"TreeNode": {
"type": "object",
"properties": {
"value": { "type": "string" },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/TreeNode" }
}
},
"required": ["value"]
}
},
"$ref": "#/$defs/TreeNode"
}Conditional Validation
Many real payloads have fields that are required only under certain conditions. JSON Schema provides several mechanisms.
if/then/else (draft-07+): when the if sub-schema passes, the payload must also satisfy then; when it fails, it must satisfy else.
{
"if": { "properties": { "type": { "const": "company" } } },
"then": { "required": ["vatNumber"] },
"else": { "required": ["personalId"] }
}allOf / anyOf / oneOf: combine multiple sub-schemas.
allOf— payload must satisfy every sub-schema. Used to extend a base schema with additional constraints.anyOf— payload must satisfy at least one sub-schema. Useful for union types.oneOf— payload must satisfy exactly one sub-schema. Useful for discriminated unions where payloads are mutually exclusive.
{
"oneOf": [
{ "$ref": "#/$defs/CreditCardPayment" },
{ "$ref": "#/$defs/BankTransferPayment" },
{ "$ref": "#/$defs/CryptoPayment" }
]
}Tooling Comparison
| Tool | Language | Drafts | Strengths |
|---|---|---|---|
| ajv | JavaScript / TypeScript | draft-04 through 2020-12 | Fastest JS validator; JIT-compiles schemas; best for Node.js APIs and CI |
| zod | TypeScript | Schema-builder (TypeScript-first) | TypeScript inference without codegen; composable; great DX in Next.js and tRPC stacks |
| valibot | TypeScript | Schema-builder (TypeScript-first) | Tree-shakeable bundle — only pays for what you use; smaller bundle impact than zod |
| Pydantic | Python | Generates draft-07 / 2020-12 | Python model validation; used by FastAPI; can export JSON Schema for cross-language use |
| jsonschema | Python | draft-03 through 2020-12 | Reference implementation for Python; good for one-off validation scripts |
For a JavaScript project validating JSON Schema documents directly, ajv is the standard choice. For TypeScript projects where you author schemas in code rather than JSON, zod or valibot provide better ergonomics. They can also export JSON Schema via adapter packages (zod-to-json-schema, @valibot/to-json-schema) when you need a portable schema artifact.
// ajv — validate against a JSON Schema document
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv();
addFormats(ajv);
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name', 'email'],
};
const validate = ajv.compile(schema);
const valid = validate({ name: 'Alice', email: 'alice@example.com' });
if (!valid) console.error(validate.errors);OpenAPI and JSON Schema
OpenAPI Specification (OAS) uses JSON Schema to describe request and response bodies. The relationship has changed across OAS versions:
- OAS 2.0 (Swagger): used a proprietary subset of JSON Schema draft-04. Keywords like
nullablewere OAS extensions, not standard JSON Schema. - OAS 3.0: used a draft-07-inspired dialect but with modifications —
nullable: trueinstead oftype: ["string", "null"], and no$refsibling keywords. - OAS 3.1: fully aligned with JSON Schema 2020-12. The dialect is declared with
jsonSchemaDialect. You can now usetype: ["string", "null"], sibling keywords alongside$ref, and all 2020-12 features.
If you are starting a new API, use OAS 3.1. If you are maintaining an OAS 3.0 spec and need to validate payloads against it, use ajv with the ajv-openapi dialect plugin rather than a plain JSON Schema validator. See also the API debug workflow guide for end-to-end request/response testing.
CI Validation Workflow
Schemas become most valuable when validation runs automatically on every pull request. The typical CI workflow has two layers: fixture validation and breaking-change detection.
1. Validate request/response fixtures against the schema:
// validate-fixtures.mjs
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { readFileSync, readdirSync } from 'fs';
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const schema = JSON.parse(readFileSync('./schemas/user.schema.json', 'utf8'));
const validate = ajv.compile(schema);
const fixturesDir = './test/fixtures/users';
let failed = 0;
for (const file of readdirSync(fixturesDir)) {
const data = JSON.parse(readFileSync(`${fixturesDir}/${file}`, 'utf8'));
if (!validate(data)) {
console.error(`FAIL ${file}:`, validate.errors);
failed++;
}
}
if (failed > 0) process.exit(1);
console.log('All fixtures valid.');2. Detect breaking schema changes between branches:
# Install json-schema-diff
npm install -g json-schema-diff
# Compare the current schema against main
json-schema-diff schemas/user.schema.json@main schemas/user.schema.json
# Fail CI if any removal or type narrowing is detected
# (additions are backward-compatible; removals are breaking)Add these steps to a GitHub Actions workflow to catch both malformed fixtures and breaking contract changes:
# .github/workflows/schema-validation.yml
name: Schema Validation
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: node validate-fixtures.mjs
- run: |
npm install -g json-schema-diff
git show origin/main:schemas/user.schema.json > /tmp/schema-main.json
json-schema-diff /tmp/schema-main.json schemas/user.schema.jsonThis two-step approach catches two distinct failure modes: a fixture that was never updated after a schema tightening, and a schema change that silently removes a field consumers depend on.
Validate schemas and payloads interactively with the JSON Schema Validator. For related topics, see the JSON formatter guide, the JSON to TypeScript types guide, and the API debug workflow guide.