Reusing Definitions With ref in JSON Schema Documents
Reuse schema pieces with $ref and $defs: local pointers, references between files by $id, recursive trees, and resolver and draft traps in Python and Node.

A schema that describes an order with a billing address and a shipping address, a customer with a home address, and a company with a registered address will repeat the same address rules four times unless it can refer to a shared definition. $ref is that reference. It works the way a function call does in code, and it comes with the same questions: where is the definition, who resolves the name, what happens when it recurses. This article uses a small address and order schema to show the three kinds of reference, and records the errors you get from Python and Node when a reference cannot be resolved.
A reference inside one document
The simplest case keeps the definition in the same file, under $defs:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"billing": { "$ref": "#/$defs/address" },
"shipping": { "$ref": "#/$defs/address" }
},
"$defs": {
"address": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"zip": { "type": "string", "pattern": "^[0-9]{5}$" }
}
}
}
}#/$defs/address is a URI fragment holding a JSON Pointer: start at the root of this document (#), go into $defs, then into address. $defs itself does nothing; it is a conventional place to put definitions, and the validator does not check anything stored there until something refers to it. In drafts up to 7 the keyword was definitions, and $defs is the name from 2019-09 on. The pointer works with either name, since it is just a path, so a schema being migrated can keep definitions and still validate.
The benefit is one place to change a rule. If the postal code pattern must accept letters for another country, the edit happens once, and both properties follow. Each reference applies the shared schema wherever it appears, so a required list inside address applies to both properties, while the decision about whether billing itself is required stays with the parent (required fields and optional fields in JSON Schema). The cost is indirection: a reader of billing must look elsewhere to see what it holds, and an error message points at the instance path, not at the definition (validating JSON with JSON Schema in real projects shows how to turn paths into readable messages).
A reference to another file
When several schemas share the address, put it in its own file with an $id and refer to it from the others:
{ "$id": "https://example.com/schemas/address.json",
"type": "object", "required": ["city"],
"properties": { "city": {"type": "string"}, "zip": {"type": "string", "pattern": "^[0-9]{5}$"} } }{ "$id": "https://example.com/schemas/order.json",
"type": "object",
"properties": { "billing": {"$ref": "address.json"}, "shipping": {"$ref": "address.json"} } }$id gives a schema its own base URI, and a relative $ref like address.json is resolved against the base URI of the schema that contains it, here https://example.com/schemas/address.json. The URI is an identifier and does not have to be a working web address. What matters is that the validator can find a schema registered under that name, and by default it does not go and fetch one. In Python, compiling the order schema without the address schema registered, then validating, failed with:
_WrappedReferencingError Unresolvable: address.jsonAjv fails at compile time, which is earlier and better: can't resolve reference address.json from id https://example.com/schemas/order.json. Both libraries stopped on purpose, since downloading a schema during validation is a way for a document to make your service fetch arbitrary URLs. You provide the referenced schemas yourself. In Python with the referencing library:
from referencing import Registry, Resource
from referencing.jsonschema import DRAFT202012
registry = Registry().with_resources([
("https://example.com/schemas/address.json", Resource.from_contents(address_schema, DRAFT202012)),
])
validator = Draft202012Validator(order_schema, registry=registry)In Ajv, call ajv.addSchema(addressSchema) before ajv.compile(orderSchema). With the address registered, a document with a bad shipping address produced exactly the errors you would hope for, with paths into the referring document: $.shipping 'city' is a required property and $.shipping.zip 'ab' does not match '^[0-9]{5}$' in Python, and /shipping/zip must match pattern "^[0-9]{5}$" in Ajv. The billing address, which was valid, produced nothing.
A schema repository with dozens of files should load every file into the registry at startup, keyed by $id, and fail the build if a reference cannot be resolved. Loading from disk once at startup avoids both the network question and the latency.
A pointer to nothing
A typo in the pointer is reported as an error at validation time in Python and at compile time in Ajv:
Python PointerToNowhere: '/$defs/nope' does not exist within {...}
Ajv can't resolve reference #/$defs/nope from id #Because Python only resolves a reference when validation reaches it, a broken $ref in a rarely used branch can sit in a schema until a document exercises it. check_schema verifies the schema against the meta-schema but does not resolve references. The defense is a test document for every $ref, or loading the schema through Ajv in CI to get a compile-time check.
Recursion
A reference can point to a definition that contains the reference, which is how trees, nested comments and menus are described. Arrays of such nodes use items with the reference, as covered in validating arrays and nested objects with JSON Schema:
{
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" },
"children": { "type": "array", "items": { "$ref": "#/$defs/node" } }
},
"additionalProperties": false
}
},
"$ref": "#/$defs/node"
}I validated a tree with two problems deep inside, and both were located:
$.children[0].children[0].name 5 is not of type 'string'
$.children[1] Additional properties are not allowed ('extra' was unexpected)The danger is depth. The validator walks the document recursively, so a document nested deeply enough exhausts the stack. Python raised RecursionError for a tree 2,000 levels deep, and Node raised RangeError: Maximum call stack size exceeded at 20,000. A request body that deep is a denial-of-service attempt, not data. Most JSON parsers also have their own depth limits, but do not rely on them: reject input beyond a sensible depth before it reaches the validator, and put a size limit on request bodies.
Siblings of $ref and the draft you are in
In draft 7 and earlier, a schema object that contains $ref is replaced by the referenced schema, and all its other keywords are ignored. From 2019-09 on, they are applied together. I tested the same idea in both:
# 2020-12
{"$ref": "#/$defs/s", "maxLength": 3, "$defs": {"s": {"type": "string"}}} # "abcdef" -> rejected
# draft 7
{"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/s", "maxLength": 3, "definitions": {"s": {"type": "string"}}} # "abcdef" -> acceptedThe 2020-12 validator rejected "abcdef" for being longer than three characters; the draft 7 validator accepted it, because maxLength next to $ref was dropped. If a schema must work in both, put the extra keywords in an allOf with the $ref: {"allOf": [{"$ref": "#/definitions/s"}, {"maxLength": 3}]} behaves the same in every draft. That is also the pattern to use when a description or default sits beside a reference. Always put $schema at the top of a file so a tool does not guess (JSON Schema guide: drafts).
Anchors, and what is less portable
$anchor names a spot in a schema so it can be referenced as #name instead of a path: "$anchor": "num" and "$ref": "#num". The Python library resolved it and validated correctly, and Ajv 8.20 stopped with strict mode: unknown keyword: "$anchor". The behavior depends on the draft class and the version, so if a schema is shared between Python and Node, prefer JSON Pointer references, which both handle. The same holds for $dynamicRef, a 2020-12 feature for extensible recursive schemas that you only need when building schema libraries.
Keeping the pieces understandable
Practices that make a schema with references readable a year later:
Name definitions after the domain,
addressandlineItem, not after the shape,objectWithCity.Keep a definition's own description and a short example inside it, since that is what an editor shows on hover.
Put one definition in one place. Two files that both define
addresswill drift.Version the
$idwhen the shape changes in an incompatible way (versioning a JSON Schema without breaking consumers).Keep the dependency graph acyclic between files, except for deliberate recursion within one definition.
When $ref makes a schema harder to read
The reuse is imaginary. A definition used once is a detour. Inline it until a second use appears.
The pieces differ slightly. Billing needs a company name and shipping does not. Combining a shared base with allOf and then closing the result needs unevaluatedProperties, not additionalProperties, or the base's own properties are rejected as unexpected. Two nearly identical definitions are sometimes clearer than a clever composition.
You export the schema for documentation. Many tools render a schema with references unexpanded. Check that the generated documentation is readable, or provide a bundled version with every reference resolved.
References are defined in the core specification under schema references, and the practical guide is Structuring a complex schema. Paste a schema into the JSON formatter to check it is well-formed before debugging a reference.


