JSON Schema Validation for REST APIs

Validate request and response bodies with Ajv, Draft 2020-12, Express middleware, and OpenAPI 3.1 integration

Last Updated:

What is JSON Schema?

JSON Schema is a vocabulary for describing the structure, constraints, and semantics of JSON documents. It is itself a JSON document. In 2026, the current stable draft is 2020-12, used natively by OpenAPI 3.1.

JSON Schema has over 60 million weekly npm downloads (via Ajv) and is language-agnostic — the same schema works for validation in Node.js, Python, Go, Java, and browsers. It is the foundation of contract testing and API governance toolchains.

// The simplest JSON Schema: accept any JSON object
{}

// A strictly typed User schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["email", "name"],
  "additionalProperties": false,
  "properties": {
    "id":    { "type": "string", "format": "uuid", "readOnly": true },
    "email": { "type": "string", "format": "email", "maxLength": 255 },
    "name":  { "type": "string", "minLength": 1, "maxLength": 100 },
    "age":   { "type": "integer", "minimum": 0, "maximum": 150 },
    "role":  { "enum": ["admin", "user", "viewer"], "default": "user" }
  }
}

Core Keywords

Type Keywords

// Single type
{ "type": "string" }

// Multiple types (Draft 2020-12 — replaces nullable: true from OpenAPI 3.0)
{ "type": ["string", "null"] }

// All JSON types: "string", "number", "integer", "boolean", "array", "object", "null"

Object Keywords

{
  "type": "object",
  "required": ["name", "email"],          // required fields list
  "properties": {
    "name": { "type": "string" },
    "email": { "type": "string" }
  },
  "additionalProperties": false,          // reject unknown fields
  "minProperties": 1,                     // at least 1 field
  "maxProperties": 50,                    // at most 50 fields
  "patternProperties": {
    "^x-": { "type": "string" }           // allow x-* extension fields
  }
}

Strings, Numbers, and Arrays

String Validation

{
  "type": "string",
  "minLength": 1,
  "maxLength": 255,
  "pattern": "^[a-zA-Z0-9_-]+$",     // regex — must match
  "format": "email"                    // semantic format check
}

// Available formats (with ajv-formats):
// "email", "uri", "uuid", "date", "time", "date-time",
// "ipv4", "ipv6", "hostname", "byte" (base64)

Number Validation

{
  "type": "number",
  "minimum": 0,
  "maximum": 100,
  "exclusiveMinimum": 0,   // > 0, not >= 0 (note: changed from boolean in Draft 7)
  "multipleOf": 0.01       // e.g. valid currency: 9.99, invalid: 9.999
}

// Integer (no decimals)
{ "type": "integer", "minimum": 1, "maximum": 9999 }

Array Validation

{
  "type": "array",
  "items": { "type": "string" },       // all items must be strings
  "minItems": 1,
  "maxItems": 100,
  "uniqueItems": true                  // no duplicates

  // Draft 2020-12: tuple validation (replaces items as array)
  // "prefixItems": [
  //   { "type": "string" },            // first item: string
  //   { "type": "number" }             // second item: number
  // ]
}

Composition: allOf, anyOf, oneOf, if/then/else

// anyOf: valid if matches ANY of the subschemas
{
  "anyOf": [
    { "type": "string" },
    { "type": "null" }
  ]
}

// oneOf: valid if matches EXACTLY ONE subschema (discriminator pattern)
{
  "oneOf": [
    {
      "properties": { "type": { "const": "email" }, "address": { "type": "string", "format": "email" } },
      "required": ["type", "address"]
    },
    {
      "properties": { "type": { "const": "phone" }, "number": { "type": "string", "pattern": "^\\+\\d{10,15}$" } },
      "required": ["type", "number"]
    }
  ]
}

// allOf: valid if matches ALL subschemas (mixin/extension pattern)
{
  "allOf": [
    { "$ref": "#/$defs/BaseEntity" },
    {
      "properties": { "email": { "type": "string", "format": "email" } },
      "required": ["email"]
    }
  ]
}

// if/then/else: conditional validation
{
  "if": { "properties": { "country": { "const": "US" } } },
  "then": { "required": ["zipCode"], "properties": { "zipCode": { "pattern": "^\\d{5}$" } } },
  "else": { "required": ["postalCode"] }
}

Ajv Setup

Ajv is the fastest JSON Schema validator for Node.js. It compiles schemas to optimized JavaScript functions at startup — individual validations are near-zero cost.

npm install ajv ajv-formats
const Ajv = require('ajv');
const addFormats = require('ajv-formats');

// Create Ajv instance — configure once, reuse everywhere
const ajv = new Ajv({
  allErrors: true,          // collect ALL errors, not just the first
  removeAdditional: 'all',  // strip unknown properties (security default)
  useDefaults: true,        // fill in "default" values from schema
  coerceTypes: false        // do NOT silently coerce types (be strict)
});
addFormats(ajv);            // adds email, uuid, date-time, uri, etc.

// Define reusable schemas
const userCreateSchema = {
  type: 'object',
  required: ['email', 'name'],
  additionalProperties: false,
  properties: {
    email: { type: 'string', format: 'email', maxLength: 255 },
    name:  { type: 'string', minLength: 1, maxLength: 100 },
    role:  { enum: ['admin', 'user', 'viewer'], default: 'user' }
  }
};

// Compile once at startup — NOT per-request
const validateUserCreate = ajv.compile(userCreateSchema);

// Use in request handler
function createUserHandler(body) {
  const valid = validateUserCreate(body);
  if (!valid) {
    const errors = validateUserCreate.errors.map(e => ({
      field: e.instancePath.slice(1) || e.params?.missingProperty || 'unknown',
      message: e.message,
      value: e.data
    }));
    throw { status: 422, errors };
  }
  // body is now validated and sanitized (unknown fields stripped)
  return body;
}

Express Validation Middleware

const express = require('express');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');

const app = express();
app.use(express.json());

const ajv = new Ajv({ allErrors: true, removeAdditional: 'all', useDefaults: true });
addFormats(ajv);

// Reusable middleware factory
function validate(schema, target = 'body') {
  const validateFn = ajv.compile(schema);

  return (req, res, next) => {
    const data = req[target]; // 'body', 'query', or 'params'
    const valid = validateFn(data);

    if (!valid) {
      return res.status(422).json({
        type: 'https://www.restguide.info/errors/validation',
        title: 'Validation Failed',
        status: 422,
        detail: `The request ${target} contains invalid data`,
        errors: validateFn.errors.map(e => ({
          field: e.instancePath.replace(/^\//, '') || e.params?.missingProperty || 'root',
          message: e.message,
          received: e.data
        }))
      });
    }

    req[target] = data; // write sanitized data back
    next();
  };
}

// Schemas
const createOrderSchema = {
  type: 'object',
  required: ['customerId', 'items'],
  additionalProperties: false,
  properties: {
    customerId:     { type: 'string', format: 'uuid' },
    idempotencyKey: { type: 'string', minLength: 8, maxLength: 64 },
    items: {
      type: 'array',
      minItems: 1,
      maxItems: 50,
      items: {
        type: 'object',
        required: ['productId', 'quantity'],
        additionalProperties: false,
        properties: {
          productId: { type: 'string', format: 'uuid' },
          quantity:  { type: 'integer', minimum: 1, maximum: 999 }
        }
      }
    }
  }
};

const listOrdersQuerySchema = {
  type: 'object',
  additionalProperties: false,
  properties: {
    page:   { type: 'string', pattern: '^[0-9]+$', default: '1' },
    limit:  { type: 'string', pattern: '^[0-9]+$', default: '20' },
    status: { enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'] }
  }
};

// Routes
app.post('/orders',
  validate(createOrderSchema, 'body'),
  async (req, res) => {
    // req.body is validated and sanitized
    const order = await createOrder(req.body);
    res.status(201).json(order);
  }
);

app.get('/orders',
  validate(listOrdersQuerySchema, 'query'),
  async (req, res) => {
    const { page, limit, status } = req.query;
    const orders = await listOrders({ page: +page, limit: +limit, status });
    res.json(orders);
  }
);

Response Validation

Validating outgoing responses catches API regressions — when a code change accidentally drops a required field from your response. Do this in development and CI, not production (performance impact).

// Response schema
const userResponseSchema = {
  type: 'object',
  required: ['id', 'email', 'createdAt'],
  properties: {
    id:        { type: 'string', format: 'uuid' },
    email:     { type: 'string', format: 'email' },
    name:      { type: ['string', 'null'] },
    createdAt: { type: 'string', format: 'date-time' }
  }
};

const validateUserResponse = ajv.compile(userResponseSchema);

// Wrap handlers in dev/test environments
function withResponseValidation(schema, handler) {
  if (process.env.NODE_ENV === 'production') return handler;

  const validateFn = ajv.compile(schema);
  return async (req, res, next) => {
    const originalJson = res.json.bind(res);
    res.json = (body) => {
      if (!validateFn(body)) {
        console.error('RESPONSE SCHEMA VIOLATION:', validateFn.errors);
        // Optionally throw in test environment:
        if (process.env.NODE_ENV === 'test') throw new Error('Invalid response shape');
      }
      return originalJson(body);
    };
    return handler(req, res, next);
  };
}

JSON Schema vs Joi vs Zod

FeatureJSON Schema + AjvJoiZod
Standard✅ IETF standard❌ Proprietary❌ Proprietary
Language-agnostic✅ Yes❌ JS only❌ TS/JS only
TypeScript inferenceVia codegen (json-schema-to-ts)❌ Limited✅ Native
OpenAPI integration✅ Native (3.1)❌ Manual❌ Manual (zod-openapi)
Performance✅ Fastest (compiled)SlowerMedium
Browser support✅ Yes✅ Yes✅ Yes
Shareable schemas✅ JSON files, any tool❌ Code only❌ Code only
Best forOpenAPI, multi-language, governanceLegacy Node.js projectsTypeScript-first apps

JSON Schema in OpenAPI 3.1

OpenAPI 3.1 uses JSON Schema Draft 2020-12 natively. You can define schemas inline or in a separate $defs / components/schemas section and $ref them:

components:
  schemas:
    # Reusable base entity
    BaseEntity:
      type: object
      required: [id, createdAt, updatedAt]
      properties:
        id:        { type: string, format: uuid, readOnly: true }
        createdAt: { type: string, format: date-time, readOnly: true }
        updatedAt: { type: string, format: date-time, readOnly: true }

    # User extends BaseEntity
    User:
      allOf:
        - $ref: '#/components/schemas/BaseEntity'
        - type: object
          required: [email]
          properties:
            email: { type: string, format: email }
            name:  { type: [string, "null"] }     # nullable in 3.1
            role:  { enum: [admin, user, viewer], default: user }

    # Discriminated union (Payment method)
    PaymentMethod:
      oneOf:
        - $ref: '#/components/schemas/CardPayment'
        - $ref: '#/components/schemas/BankTransfer'
      discriminator:
        propertyName: type
        mapping:
          card: '#/components/schemas/CardPayment'
          bank: '#/components/schemas/BankTransfer'

Keep schemas in a standalone schemas/ directory as pure JSON Schema files and $ref them from your OpenAPI document. They stay reusable outside the API context — for database validation, message queues, and generated TypeScript types. See our OpenAPI 3.1 guide for the full spec workflow.

Frequently Asked Questions

What is the difference between JSON Schema and JSON?

JSON is a data format. JSON Schema is a vocabulary for describing the structure and constraints of JSON data. A schema document is itself valid JSON but describes rules — types, required fields, string patterns, numeric ranges — that other JSON documents must follow.

Which JSON Schema draft should I use in 2026?

Use Draft 2020-12 for new projects — it is the current stable version, used natively by OpenAPI 3.1, and fully supported by Ajv 8+. Only stay on Draft 7 for specific compatibility requirements (some older tools).

Should I use JSON Schema, Joi, or Zod?

JSON Schema is best for vendor-neutral standards, OpenAPI integration, and multi-language codebases. Zod is best for TypeScript-first projects where type inference matters. Joi is mature but has a weaker TypeScript story. For most REST APIs in 2026, JSON Schema with Ajv (or Zod for TypeScript) are the recommended choices.

What does additionalProperties: false do?

It rejects any property not explicitly listed under properties. This is a security best practice — it prevents clients from injecting unexpected fields. Pair with removeAdditional: 'all' in Ajv to strip unknown fields silently instead of rejecting them.

Related Topics

JSON Schema is the foundation of OpenAPI 3.1 schema definitions. Always validate request bodies as a core best practice — it is your first line of defense. In API testing, schema-based contract validation ensures your responses never drift from your declared spec.