JSON Schema Validation & JSONPath Queries

Protocols & Interoperability intermediate Building with AI ~25 min read

Overview

JSON Schema is a declarative language for defining the structure, content, and constraints of JSON documents. It specifies what fields a document must have, what types those fields should be, which values are acceptable, and how nested objects should be structured. By validating documents against a schema at system boundaries—API ingress, configuration loading, data import—you catch structural errors before they propagate into business logic, turning runtime surprises into explicit validation failures.

JSONPath is a query language for extracting values from JSON documents, analogous to XPath for XML. Where JSON Schema answers “is this document valid?”, JSONPath answers “give me these specific values from this document.” Together, they form a complete toolkit for working with JSON data: Schema enforces the contract, and JSONPath navigates the structure to extract what you need.

In a document database system, JSON Schema validation is used to enforce document structures at ingestion time—every document entering the database must conform to its collection’s schema. JSONPath queries power the extraction of nested fields for indexing, filtering, and projection. Sharing compatible schema definitions across implementations ensures that documents validated by one system are readable by another.

Core Concepts

JSON Schema Vocabulary

A JSON Schema document is itself JSON. It uses keywords like type, properties, required, items, and additionalProperties to describe the expected shape of a target document. Schemas can reference other schemas via $ref, enabling composition: a shared “address” schema can be reused across “user,” “company,” and “order” schemas without duplication.

Key Concept: Schema as Contract

A JSON Schema is a machine-readable API contract. Unlike documentation that can drift from reality, a schema is enforced at runtime. If a producer sends a document that violates the schema, the consumer rejects it immediately with a specific error pointing to the exact field and constraint that failed. This shifts error detection from “undefined behavior deep in the code” to “rejected at the gate with a clear message.”

Validation Keywords

JSON Schema provides a rich set of validation constraints: minimum/maximum for numbers, minLength/maxLength for strings, pattern for regex matching, enum for allowed values, format for semantic types (email, date-time, URI), and oneOf/anyOf/allOf for composing complex constraints. These keywords eliminate entire categories of validation code that would otherwise be scattered across application logic.

Key Concept: Declarative vs. Imperative Validation

Instead of writing if typeof field !== "string" || field.length > 255 in every handler, you declare {"type": "string", "maxLength": 255} once in the schema. The validator library handles traversal, type checking, and error reporting. This is more maintainable (change the constraint in one place), more consistent (every consumer validates the same way), and more discoverable (the schema doubles as documentation).

JSONPath Syntax

JSONPath expressions select elements from a JSON document using dot notation and bracket syntax. The root element is $, child access is $.field or $['field'], array indexing is $[0], wildcards are $.* or $[*], recursive descent is $.., and filter expressions use $[?(@.price < 10)]. These expressions can extract deeply nested values without writing traversal code.

Key Concept: JSONPath as a Query Language

JSONPath treats a JSON document as a tree and lets you address nodes by path, just as a filesystem path addresses files. $.store.book[0].title navigates from root to the store object, into its book array, selects the first element, and returns its title. This is far more concise and less error-prone than manual traversal: doc["store"]["book"][0]["title"] with nil checks at every level.

Schema Composition with $ref

Large schemas are composed from smaller, reusable pieces using $ref pointers. A $ref value like "#/$defs/address" references a schema defined in the same document’s $defs section. External references like "https://example.com/schemas/address.json" point to schemas hosted elsewhere. This enables a library of shared type definitions that multiple schemas reference, ensuring consistency across an organization’s API surface.

How It Works

Schema validation traverses the target document and the schema in parallel, checking each node against its corresponding schema constraints. JSONPath evaluation traverses the document following the path expression, collecting matching nodes. Both operations are tree walks with different goals: validation produces a pass/fail result with error details, while JSONPath produces a set of matched values.

Validation Flow

  1. Parse: Deserialize the JSON document and the schema into in-memory structures.
  2. Compile schema: Resolve all $ref pointers, precompute regex patterns, and build a validator tree.
  3. Traverse: Walk the document tree. At each node, apply the corresponding schema’s type check, constraint checks, and recursively validate child nodes.
  4. Collect errors: Accumulate all validation failures with their JSON Pointer paths (e.g., /users/0/email) so the caller knows exactly which fields failed and why.
// Go: JSON Schema validation using gojsonschema
package main

import (
    "fmt"
    "github.com/xeipuuv/gojsonschema"
)

func validateDocument(schemaJSON, documentJSON string) error {
    schemaLoader := gojsonschema.NewStringLoader(schemaJSON)
    documentLoader := gojsonschema.NewStringLoader(documentJSON)

    result, err := gojsonschema.Validate(schemaLoader, documentLoader)
    if err != nil {
        return fmt.Errorf("validation setup failed: %w", err)
    }

    if !result.Valid() {
        for _, desc := range result.Errors() {
            fmt.Printf("- %s: %s\n", desc.Field(), desc.Description())
        }
        return fmt.Errorf("document has %d validation errors", len(result.Errors()))
    }

    return nil
}

// Example schema for a database document
const documentSchema = `{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "content", "metadata"],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^[a-f0-9]{32}$"
    },
    "content": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1048576
    },
    "metadata": {
      "type": "object",
      "required": ["source", "created_at"],
      "properties": {
        "source": { "type": "string" },
        "created_at": { "type": "string", "format": "date-time" },
        "tags": {
          "type": "array",
          "items": { "type": "string" },
          "uniqueItems": true
        }
      },
      "additionalProperties": true
    },
    "embeddings": {
      "type": "array",
      "items": { "type": "number" },
      "minItems": 128,
      "maxItems": 1536
    }
  },
  "additionalProperties": false
}`
// Go: JSONPath queries for extracting nested values
package main

import (
    "encoding/json"
    "fmt"
    "github.com/PaesslerAG/jsonpath"
)

func queryDocument(docJSON string, path string) (interface{}, error) {
    var doc interface{}
    if err := json.Unmarshal([]byte(docJSON), &doc); err != nil {
        return nil, fmt.Errorf("parse JSON: %w", err)
    }

    result, err := jsonpath.Get(path, doc)
    if err != nil {
        return nil, fmt.Errorf("jsonpath %q: %w", path, err)
    }

    return result, nil
}

func main() {
    doc := `{
      "store": {
        "book": [
          {"title": "HNSW Indexing", "price": 29.99, "tags": ["search", "algorithms"]},
          {"title": "LSM Trees in Practice", "price": 39.99, "tags": ["database", "storage"]},
          {"title": "Raft Consensus", "price": 34.99, "tags": ["distributed", "consensus"]}
        ]
      }
    }`

    // Get all book titles
    titles, _ := queryDocument(doc, "$.store.book[*].title")
    fmt.Println("Titles:", titles)

    // Get the first book's tags
    tags, _ := queryDocument(doc, "$.store.book[0].tags")
    fmt.Println("First book tags:", tags)

    // Get books with price under 35
    cheap, _ := queryDocument(doc, "$.store.book[?(@.price < 35)]")
    fmt.Println("Under $35:", cheap)
}

Practical Applications

API Request Validation

Every API endpoint that accepts JSON should validate the request body against a schema before processing it. This catches malformed requests at the handler boundary, producing clear 400 errors instead of obscure 500 errors deep in the application. In a document database, insert and update endpoints validate the incoming document against the collection’s schema before writing to storage.

Configuration File Validation

Application configuration files (JSON, YAML converted to JSON) are validated at startup against a schema that defines required fields, allowed values, and type constraints. This catches typos ("levle": "debug" instead of "level": "debug") and missing required fields before the application starts serving traffic. The schema itself documents every configuration option and its constraints.

Document Field Extraction for Indexing

When a document database indexes a document, it uses JSONPath expressions to extract specific fields for different index types. A full-text search index extracts $.content, a faceted filter index extracts $.metadata.tags[*], and a date-range index extracts $.metadata.created_at. These extraction paths are configured per collection, not hardcoded, making the indexing system generic across document structures.

Schema Evolution and Migration

As data models evolve, schemas version alongside them. A v2 schema might add a required field with a default value, or deprecate an old field by making it optional. Validation can be run against multiple schema versions to classify documents and apply appropriate migration logic. This is how a document database handles rolling upgrades without requiring downtime or batch migration of all existing documents.

Implementation Patterns

Schema Compilation and Caching

Parsing and compiling a JSON Schema involves resolving references, compiling regex patterns, and building a validator tree. This is expensive and should be done once at startup, not on every validation call. Cache compiled schemas by their URI or version identifier. In In a document database, each collection’s compiled schema is loaded when the collection is opened and reused for all subsequent document validations.

Coercion vs. Strict Validation

Some validators can coerce values to the expected type: the string "42" becomes the number 42 if the schema says "type": "number". This is useful for form data or query parameters that are always strings. However, for API contracts and database storage, strict validation (reject type mismatches) is safer because coercion can mask bugs in upstream producers.

Key Concept: Validate at Boundaries, Trust Internally

Validate JSON documents at system boundaries: API ingress, file loading, message consumption. Once a document passes validation, internal code can trust its structure without redundant checks. This principle reduces defensive programming clutter while maintaining safety. The schema is the gatekeeper; code behind the gate operates on trusted data.

Error Reporting with JSON Pointer

Validation errors should include a JSON Pointer (RFC 6901) to the failing field: /metadata/tags/2 tells the caller exactly which element in which nested structure failed. Without this, error messages like “string expected” force the caller to guess which of dozens of string fields is wrong. Good error reporting is the difference between a five-second fix and a thirty-minute debugging session.

// Zig: Minimal JSON Schema type validation
const std = @import("std");
const json = std.json;

const SchemaType = enum {
    string,
    number,
    integer,
    boolean,
    array,
    object,
    null_type,
};

const ValidationError = struct {
    path: []const u8,
    message: []const u8,
};

fn validateType(
    value: json.Value,
    expected: SchemaType,
    path: []const u8,
    errors: *std.ArrayList(ValidationError),
) !void {
    const matches = switch (expected) {
        .string => value == .string,
        .number => value == .float or value == .integer,
        .integer => value == .integer,
        .boolean => value == .bool,
        .array => value == .array,
        .object => value == .object,
        .null_type => value == .null,
    };

    if (!matches) {
        try errors.append(.{
            .path = path,
            .message = "type mismatch",
        });
    }
}

fn validateRequired(
    obj: json.ObjectMap,
    required_fields: []const []const u8,
    path: []const u8,
    errors: *std.ArrayList(ValidationError),
) !void {
    for (required_fields) |field| {
        if (obj.get(field) == null) {
            // Build path like "/metadata/source"
            var field_path = std.ArrayList(u8).init(std.heap.page_allocator);
            try field_path.appendSlice(path);
            try field_path.append('/');
            try field_path.appendSlice(field);
            try errors.append(.{
                .path = field_path.items,
                .message = "required field missing",
            });
        }
    }
}

Common Pitfalls

Further Reading