JSON Schema $defs: Reusable Definitions and $ref

Last updated:

JSON Schema's $defs keyword lets you define named schemas once and reference them anywhere in the same document — or across multiple files — using $ref. This guide covers everything from basic definition reuse to recursive schemas, cross-file references with $id, Ajv's addSchema() API, and bundling schemas for distribution.

$defs vs definitions (Draft-07)

In JSON Schema Draft-07, the community convention was to store reusable schemas under a top-level definitions key. Draft 2019-09 formalized this as $defs — the same concept, just a standardized keyword name. Both Ajv 8 and most modern validators support both spellings.

DraftKeyword$ref to itNotes
Draft-07definitions#/definitions/NameInformal convention, not in official vocabulary
Draft 2019-09+$defs#/$defs/NameOfficial keyword; preferred for all new schemas

When migrating from Draft-07, a search-and-replace of definitions -> $defs (and #/definitions/ -> #/$defs/) covers most schemas. Neither keyword validates anything by itself — both are purely a storage location for named sub-schemas that are inert until referenced.

// Draft-07 style (still valid, still supported by Ajv 8)
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "definitions": {
    "Email": { "type": "string", "format": "email" }
  },
  "type": "object",
  "properties": {
    "email": { "$ref": "#/definitions/Email" }
  }
}

// Draft 2019-09+ / 2020-12 style (preferred)
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "Email": { "type": "string", "format": "email" }
  },
  "type": "object",
  "properties": {
    "email": { "$ref": "#/$defs/Email" }
  }
}

Defining and Referencing with $ref

$ref is a JSON Pointer URI that tells the validator to apply the schema found at that location. The fragment #/$defs/Address means: start at the document root (#), navigate to the $defs key, then to the Address key.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "Address": {
      "type": "object",
      "properties": {
        "street":  { "type": "string" },
        "city":    { "type": "string" },
        "country": { "type": "string", "pattern": "^[A-Z]{2}$" },
        "zip":     { "type": "string" }
      },
      "required": ["street", "city", "country"]
    },
    "Money": {
      "type": "object",
      "properties": {
        "amount":   { "type": "number", "minimum": 0 },
        "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
      },
      "required": ["amount", "currency"]
    }
  },
  "type": "object",
  "properties": {
    "billingAddress":  { "$ref": "#/$defs/Address" },
    "shippingAddress": { "$ref": "#/$defs/Address" },
    "total":           { "$ref": "#/$defs/Money" },
    "tax":             { "$ref": "#/$defs/Money" }
  },
  "required": ["billingAddress", "total"]
}

Both billingAddress and shippingAddress reference the same Address definition. Update it once, and both properties pick up the change. In Draft 2019-09+, you can add sibling keywords alongside $ref — for example, adding a description or even additional constraints:

// Draft 2019-09+: sibling keywords alongside $ref are evaluated
"billingAddress": {
  "$ref": "#/$defs/Address",
  "description": "The address used for the invoice"
}

// Draft-07: sibling keywords are IGNORED — $ref replaces the entire schema
// Only use plain { "$ref": "..." } in Draft-07

JSON Pointer Syntax

JSON Pointer (RFC 6901) paths use / to separate keys. Two escape sequences handle special characters: ~1 for a literal slash in a key, ~0 for a literal tilde.

// Navigating nested paths
"$ref": "#/properties/user/properties/email"
// root → properties → user → properties → email

// Keys with slashes: encode / as ~1
// Key named "a/b" → reference as "a~1b"
"$ref": "#/$defs/a~1b"

// Keys with tildes: encode ~ as ~0
// Key named "a~b" → reference as "a~0b"
"$ref": "#/$defs/a~0b"

Recursive Schemas

Self-referential $ref enables validation of arbitrarily deep tree structures. A schema in $defs can reference itself — Ajv resolves such circular references lazily.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "TreeNode": {
      "type": "object",
      "properties": {
        "id":       { "type": "string" },
        "label":    { "type": "string" },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/TreeNode" }  // recursive reference
        }
      },
      "required": ["id", "label"]
    }
  },
  "$ref": "#/$defs/TreeNode"  // root schema delegates to the recursive definition
}

// Valid data — arbitrary depth
{
  "id": "root",
  "label": "Electronics",
  "children": [
    {
      "id": "phones",
      "label": "Phones",
      "children": [
        { "id": "android", "label": "Android", "children": [] }
      ]
    },
    { "id": "laptops", "label": "Laptops", "children": [] }
  ]
}

$dynamicRef for Extensible Recursion (Draft 2020-12)

Draft 2020-12 introduced $dynamicRef and $dynamicAnchor for recursive schemas that need to be extensible — where a derived schema can override the recursive behavior. This is useful for library schemas that consumers extend.

// Base schema using $dynamicAnchor
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/tree.json",
  "$dynamicAnchor": "node",
  "type": "object",
  "properties": {
    "data": true,
    "children": {
      "type": "array",
      "items": { "$dynamicRef": "#node" }  // resolved at validation time, not definition time
    }
  }
}

// Extended schema — overrides the recursive type to add constraints
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/strict-tree.json",
  "$dynamicAnchor": "node",   // "re-binds" the anchor to THIS stricter schema
  "$ref": "https://example.com/tree.json",
  "properties": {
    "data": { "type": "string" }  // narrowed: data must be a string
  }
}

Multi-File Schemas with $id and $ref

$id sets the base URI of a schema, which makes it addressable from other schemas via $ref. Splitting large schemas into separate files improves maintainability and enables reuse across projects.

// address.schema.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/address.json",
  "type": "object",
  "properties": {
    "street":  { "type": "string" },
    "city":    { "type": "string" },
    "country": { "type": "string", "pattern": "^[A-Z]{2}$" }
  },
  "required": ["street", "city", "country"]
}

// order.schema.json — references address.schema.json by $id URI
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/order.json",
  "type": "object",
  "properties": {
    "orderId":         { "type": "string" },
    "billingAddress":  { "$ref": "https://schemas.example.com/address.json" },
    "shippingAddress": { "$ref": "https://schemas.example.com/address.json" }
  },
  "required": ["orderId", "billingAddress"]
}

You can also reference a specific sub-schema inside an external file by appending a JSON Pointer fragment:

// Reference a specific $defs entry in an external file
{
  "$ref": "https://schemas.example.com/common.json#/$defs/Money"
}

// Relative URI — resolved relative to the current schema's $id
{
  "$ref": "./address.schema.json"
}
{
  "$ref": "../common/money.schema.json#/$defs/Money"
}

Named Anchors with $anchor

In Draft 2020-12, the $anchor keyword assigns a plain-name anchor to any schema — an alternative to JSON Pointer paths for stable, human-readable references.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.example.com/common.json",
  "$defs": {
    "Address": {
      "$anchor": "address",   // named anchor — stable even if the $defs key is renamed
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city":   { "type": "string" }
      }
    }
  }
}

// Reference by anchor name — more readable than a JSON Pointer
{ "$ref": "https://schemas.example.com/common.json#address" }

// vs JSON Pointer reference (same target)
{ "$ref": "https://schemas.example.com/common.json#/$defs/Address" }

Ajv Schema Registration

Ajv does not fetch schemas over the network at runtime. All external schemas referenced by $ref must be pre-registered with ajv.addSchema() before compiling a schema that references them.

import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import addressSchema from './address.schema.json'
import commonSchema from './common.schema.json'
import orderSchema from './order.schema.json'

const ajv = new Ajv({ allErrors: true })
addFormats(ajv)

// Register external schemas BEFORE compiling the root schema
ajv.addSchema(addressSchema)  // uses the schema's own $id as the key
ajv.addSchema(commonSchema)

// Now compile the root schema — $ref to address.json will resolve from cache
const validate = ajv.compile(orderSchema)

const order = {
  orderId: 'ord_001',
  billingAddress: { street: '123 Main St', city: 'Springfield', country: 'US' }
}

const valid = validate(order)
if (!valid) {
  console.error(validate.errors)
}

getSchema and removeSchema

// Check if a schema is registered
const schema = ajv.getSchema('https://schemas.example.com/address.json')
if (!schema) {
  ajv.addSchema(addressSchema)
}

// Remove a schema from the cache (e.g., to re-register an updated version)
ajv.removeSchema('https://schemas.example.com/address.json')
ajv.addSchema(updatedAddressSchema)

// Add schema with an explicit URI key (overrides the schema's own $id)
ajv.addSchema(addressSchema, 'https://schemas.example.com/address.json')

// List all registered schema URIs (for debugging)
// Note: Ajv doesn't expose a direct list API — track URIs in your own Set

TypeScript: Infer Types from Schemas

import Ajv, { JSONSchemaType } from 'ajv'

interface Address {
  street: string
  city: string
  country: string
}

const ajv = new Ajv()

const addressSchema: JSONSchemaType<Address> = {
  type: 'object',
  properties: {
    street:  { type: 'string' },
    city:    { type: 'string' },
    country: { type: 'string' }
  },
  required: ['street', 'city', 'country'],
  additionalProperties: false
}

// validate is typed: (data: unknown) => data is Address
const validate = ajv.compile<Address>(addressSchema)

function processAddress(data: unknown) {
  if (validate(data)) {
    // data is narrowed to Address here
    console.log(data.city)
  }
}

Schema Bundling with json-schema-ref-parser

Schema bundling inlines all external $ref targets into a single self-contained schema document. This is the recommended approach for distributing schemas in npm packages or embedding them in applications.

npm install @apidevtools/json-schema-ref-parser
import $RefParser from '@apidevtools/json-schema-ref-parser'

// bundle() inlines all external $ref as local $defs entries
async function bundleSchema(entryPath: string) {
  const bundled = await $RefParser.bundle(entryPath)
  return bundled
}

// dereference() fully inlines every $ref — no $ref remains in the output
// WARNING: this can produce very large schemas for deeply nested structures
async function dereferenceSchema(entryPath: string) {
  const derefed = await $RefParser.dereference(entryPath)
  return derefed
}

// Example: bundle order.schema.json (which references address.schema.json)
const bundled = await bundleSchema('./schemas/order.schema.json')
// Result: a single document where address.json's content is inlined
// as a $defs entry and $ref values point to "#/$defs/..." internally

bundle() vs dereference()

MethodOutputUse when
bundle()Single doc; external refs become local $defsDistribution, npm packages, CI artifact
dereference()All $ref fully inlined (no refs remain)Tooling that doesn't support $ref at all
resolve()Map of URI -> schema (no mutation)Inspecting all referenced schemas
// Build script: bundle schemas at build time, commit the output
import { writeFileSync } from 'fs'
import $RefParser from '@apidevtools/json-schema-ref-parser'

const bundled = await $RefParser.bundle('./src/schemas/api.schema.json')
writeFileSync('./dist/schemas/api.bundled.json', JSON.stringify(bundled, null, 2))
console.log('Schema bundled successfully')

// package.json
{
  "scripts": {
    "build:schemas": "tsx scripts/bundle-schemas.ts",
    "build": "npm run build:schemas && tsc"
  }
}

Common Pitfalls

1. Sibling keywords with $ref in Draft-07

In Draft-07, a $ref completely replaces the schema object — any sibling keywords like description or title are silently ignored. In Draft 2019-09+, sibling keywords are evaluated alongside the referenced schema.

// WRONG in Draft-07: description is ignored, $ref takes over entirely
"email": {
  "$ref": "#/$defs/Email",
  "description": "This is silently ignored in Draft-07"
}

// CORRECT for Draft-07: put description inside the $defs definition
"$defs": {
  "Email": {
    "type": "string",
    "format": "email",
    "description": "An RFC 5321 email address"
  }
}

// FINE in Draft 2019-09+: sibling keywords evaluated normally
"email": {
  "$ref": "#/$defs/Email",
  "description": "This works correctly in Draft 2019-09+"
}

2. additionalProperties: false in reusable base schemas

Adding additionalProperties: false to a $defs schema that is meant to be composed with allOf will cause the composition to reject any properties added by the extending schema. Use unevaluatedProperties: false (Draft 2019-09+) in the final composed schema instead.

// WRONG: additionalProperties: false in a base definition breaks allOf
"$defs": {
  "BaseUser": {
    "type": "object",
    "properties": { "id": { "type": "string" }, "email": { "type": "string" } },
    "additionalProperties": false  // ← this will reject "adminLevel" from AdminUser
  }
}

// CORRECT: no additionalProperties in the reusable base
"$defs": {
  "BaseUser": {
    "type": "object",
    "properties": { "id": { "type": "string" }, "email": { "type": "string" } }
    // no additionalProperties here
  },
  "AdminUser": {
    "allOf": [
      { "$ref": "#/$defs/BaseUser" },
      {
        "properties": { "adminLevel": { "type": "integer" } },
        "required": ["adminLevel"]
      }
    ],
    "unevaluatedProperties": false  // ← put it here, in the final schema
  }
}

3. Missing $id on external schemas prevents $ref resolution

When using multi-file schemas, every referenced schema file should have a $id matching the URI used in $ref. Without it, Ajv cannot match the registered schema to the $ref URI.

// WRONG: address.schema.json has no $id
// { "type": "object", "properties": { ... } }
// ajv.addSchema(addressSchema) — Ajv registers it under undefined URI
// $ref: "https://schemas.example.com/address.json" — won't resolve!

// CORRECT: address.schema.json has a matching $id
{
  "$id": "https://schemas.example.com/address.json",
  "type": "object",
  "properties": { "street": { "type": "string" } }
}
// ajv.addSchema(addressSchema) — registered under "https://schemas.example.com/address.json"
// $ref: "https://schemas.example.com/address.json" — resolves correctly

// ALTERNATIVE: provide the URI explicitly in addSchema
ajv.addSchema(addressSchema, 'https://schemas.example.com/address.json')

4. JSON Pointer path errors

A typo in a $ref JSON Pointer will cause a "schema not found" error at compile time in Ajv. Common mistakes include using the wrong draft's keyword (#/definitions/ vs #/$defs/) or a mismatched key name.

// WRONG: using Draft-07 path with a Draft 2020-12 schema
{ "$ref": "#/definitions/Address" }  // "definitions" key doesn't exist

// WRONG: case typo
{ "$ref": "#/$defs/address" }  // but the key is "Address" (capital A)

// CORRECT
{ "$ref": "#/$defs/Address" }

// Debugging: use ajv.getSchema to confirm registration
const found = ajv.getSchema('https://schemas.example.com/address.json')
console.log('Registered:', !!found)

5. Forgetting to register schemas before compile()

Ajv compiles schemas eagerly — all $ref targets must be registered before calling ajv.compile(rootSchema). Calling addSchema() after compile will not update the already-compiled validator.

// WRONG: compile before addSchema
const validate = ajv.compile(orderSchema)  // ← $ref to address.json fails here
ajv.addSchema(addressSchema)               // ← too late

// CORRECT: register all dependencies first
ajv.addSchema(addressSchema)
ajv.addSchema(commonSchema)
const validate = ajv.compile(orderSchema)  // ← all $ref targets are resolved

Definitions

$defs
A JSON Schema keyword (Draft 2019-09+) that stores named sub-schemas in a top-level object. The sub-schemas are inert until referenced with $ref. Replaces the informal definitions keyword used in Draft-07.
$ref
A JSON Schema keyword whose value is a URI (or URI fragment) pointing to another schema. The validator applies the referenced schema in place of the $ref object. In Draft-07, sibling keywords are ignored; in Draft 2019-09+, sibling keywords are evaluated alongside the referenced schema.
$id
A JSON Schema keyword that sets the base URI of a schema document or sub-schema. Makes a schema addressable via $ref from other schemas. At the root level it identifies the whole document; inside $defs it can create named anchors. In Draft 2020-12, $anchor is the preferred way to create plain-name anchors.
JSON Pointer
A string syntax defined in RFC 6901 for navigating to a value within a JSON document. A pointer is a sequence of /key segments starting from the document root. Used as the fragment portion of $ref URIs (e.g., #/$defs/Address). Special characters are encoded: / -> ~1, ~ -> ~0.
Schema bundling
The process of resolving all external $ref URIs in a schema and inlining the referenced schemas as local $defs entries, producing a single self-contained document. Performed by tools like json-schema-ref-parser. Recommended for distributing schemas in npm packages or embedding them in deployed applications where file system access is unavailable.

FAQ

What is the difference between $defs and definitions in JSON Schema?

$defs is the official keyword from Draft 2019-09+; definitions was an informal convention used in Draft-07 and earlier. Both store named sub-schemas and work identically at runtime — the difference is vocabulary membership. Ajv 8 supports both. For new schemas always use $defs; keep definitions only in schemas that must stay compatible with Draft-07 tooling like some older OpenAPI 2.0 validators.

How does $ref work in JSON Schema?

$ref takes a URI or URI fragment as its value and tells the validator to apply the schema found at that location. Fragment references starting with # are JSON Pointers into the current document (or the document identified by the base $id). In Draft-07, encountering a $ref causes the validator to ignore all sibling keywords — only the referenced schema applies. In Draft 2019-09+, sibling keywords are also evaluated, allowing descriptions and additional constraints alongside a $ref.

What is $id used for in JSON Schema?

$id sets the base URI of a schema, making it addressable from other schemas. At the root level, {"$id": "https://example.com/order.json"} means this schema can be referenced by that URI. Inside $defs, a fragment-form $id (Draft 2019-09) or $anchor (Draft 2020-12) creates a named anchor that can be used instead of a JSON Pointer path. Validators use $id to build a URI resolution context, so relative $ref values are resolved relative to the nearest ancestor $id.

Can $ref be circular or recursive in JSON Schema?

Yes. JSON Schema explicitly supports circular $ref for validating recursive data structures like trees, linked lists, and nested menus. Ajv resolves circular references lazily and handles them correctly. The standard pattern is to define the recursive type in $defs and have the children array's items reference that same $defs entry. For more flexible extensible recursion in Draft 2020-12, use $dynamicRef and $dynamicAnchor.

How do I reference an external schema file with $ref?

Use an absolute URI matching the external schema's $id: {"$ref": "https://schemas.example.com/address.json"}, or a relative path resolved against the current schema's $id. For runtime validation with Ajv, pre-register all external schemas with ajv.addSchema() before compiling any schema that references them. For build-time bundling, use json-schema-ref-parser to produce a single self-contained schema document.

What is JSON Pointer and how does it relate to $ref?

JSON Pointer (RFC 6901) is a path syntax for locating a value inside a JSON document, using /-separated key segments. In $ref, the fragment portion (after #) is always a JSON Pointer. For example, #/$defs/Address means: start at the document root, navigate to $defs, then to Address. To reference a key whose name contains a slash, encode it as ~1; for a tilde, use ~0.

How does Ajv addSchema work for multi-file schemas?

ajv.addSchema(schema) registers a schema object in Ajv's internal cache using the schema's own $id as the lookup key. ajv.addSchema(schema, uri) registers it under an explicit URI, overriding the $id. When Ajv compiles a schema that contains a $ref, it looks up the URI in the cache. If not found, compilation throws a "schema not found" error. Always register all dependencies before calling ajv.compile(). Ajv does not fetch schemas from the network at runtime.

What is schema bundling and when should I use it?

Schema bundling resolves all external $ref URIs and inlines the referenced schemas as $defs entries in a single document. Use it when distributing a schema in an npm package, when publishing an OpenAPI spec for offline tooling, or when embedding schemas in a deployed application without file system access. json-schema-ref-parser's bundle() method performs this. The result is a portable single-file schema where all $ref values point to local #/$defs/... fragments.

Further reading and primary sources