Reusing JSON Schema: $ref, $defs, and allOf/oneOf Composition
Bottom line: $ref solves reuse, allOf and oneOf solve composition, and stacking both is where things break. Covers $id, $defs, conditionals, recursion, bundling and debugging.

Bottom line: extract shared structures into $defs with $ref first, then reach for allOf/oneOf; when branches can match at once, use anyOf or a const tag, and reserve oneOf for cases that truly require exactly one match.
Copy-paste saves ten minutes and costs you a silent fork three months later, when nobody remembers which copy is correct.
1. Three layers of reuse — pick one before you split files
The first instinct, once fields start multiplying, is to stuff every constraint into a single file. It is the fastest way to ship, but past a couple of hundred lines every field change has to be mirrored in three places. Miss one and you get "passes locally, returns 500 in production". Reuse means writing a fact once and referencing it everywhere else.
JSON Schema offers three layers, each with a different scope:
| Layer | Carrier | Scope | Typical use |
|---|---|---|---|
| Sub-structure | $defs + $ref |
One schema file | Address, money, pagination, error shapes |
| File | Standalone schema + $id |
Several files in one service | User, order, product entities |
| Contract library | Schema repository + bundled artifact | Multiple services, multiple languages | OpenAPI components, published contracts |
For most projects the first layer is enough. Splitting into files only pays off when two services genuinely share the same contract, or when the schema is a published artifact consumed by another language. Splitting too early makes $id resolution and relative paths harder to debug, and that cost usually exceeds what you saved by avoiding duplication.
A simple rule of thumb: if a structure appears in two places, one copy is tolerable; when it appears in a third, promote it to $defs; only when another language or repository must read it does it deserve a standalone file with its own $id.
2. $id establishes identity, $ref performs the jump
$id is not a comment. It declares the schema's base URI. A $ref value is a URI reference: it is resolved against the current base URI into an absolute URI, and then the fragment after # locates a node. If the base URI is wrong, every relative reference points somewhere else, yet the error usually only says "can't resolve reference" without naming the wrong host.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo uses a JSON Pointer while billTo uses an anchor; both land on the same node. The table below maps each form to its resolution:
| Form | Points to | Draft | Notes |
|---|---|---|---|
# |
Root of the current schema | All | Common for recursion |
#/$defs/address |
Node under $defs |
2019-09 and later | Clearest; preferred |
#address |
Named anchor | $anchor in 2020-12 |
Draft-07 wrote $id: "#address" |
common.json |
File relative to the base URI | All | Must be registered or bundled |
https://example.com/x.json |
Absolute URI | All | Avoid fetching at runtime in production |
One subtle trap: if a subschema inside $defs declares its own $id, that becomes the new base URI for everything below it. Paste a schema copied from another file and the leftover $id will redirect its relative references to the original host — and the validator only notices when it actually tries to resolve them.
3. $defs is a container, not a validation site
$defs is an ordinary map. Nothing inside it takes effect until a $ref points there. A required list sitting at the top of $defs with no incoming reference validates exactly nothing, no matter how strict it looks. That is the specification working as intended, not a validator bug.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "A two-decimal money string that avoids floating-point drift"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
A few conventions are worth fixing in a team:
- Keys inside
$defscarry no semantics.address,Address, andaddrare all legal, so agree on a style. - Before 2019-09 the keyword was
definitions. Both can be targeted by$ref, but strict mode may reportdefinitionsas an unknown keyword. $defscan hold any schema, including anallOffragment or an independent subschema with its own$id.- Do not treat
$defsas template inheritance. It only stores definitions; meaning still comes from the referencing context.
4. allOf, anyOf, and oneOf are not interchangeable
All three involve multiple branches, but their acceptance rules differ. When data matches two branches, allOf and anyOf pass while oneOf fails — the single most common reason people conclude "the schema is broken".
| Keyword | Minimum matches | Maximum matches | Data matching two branches | Error quality | Typical use |
|---|---|---|---|---|---|
allOf |
All | Unlimited | Pass | Good, one constraint at a time | Combine constraint fragments |
anyOf |
1 | Unlimited | Pass | Poor, branches pile up | "Email or phone, either one" |
oneOf |
1 | 1 | Fail | Poor | Discriminated unions |
not |
0 | 0 | Fail | Worst | Excluding a few values; rarely used |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf does not merge object definitions; it only requires every branch to hold. Each branch is evaluated independently, so an additionalProperties: false inside one branch knows only the keys that branch declared and will flag fields declared by another branch as extra. In 2020-12 the fix is to put the unknown-property policy at the top level and use unevaluatedProperties: false, which is evaluated once after all branches.
Use anyOf for "several legal shapes", not for "several mutually exclusive shapes". If two branches can match at once, oneOf produces the famously unhelpful must match exactly one schema in oneOf.
5. if/then/else and discriminator: make conditions decidable
Conditional constraints tend to produce better errors than oneOf because the decision path is explicit: if passes, then applies; otherwise else applies.
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
Three details determine whether it behaves as intended:
- A
propertiesentry insideifdoes not fail when the key is missing. Pair it withrequiredto express "kind exists and equals image". ifitself never produces a validation error. Failing it simply routes toelse, so it cannot block bad data on its own.- A lone
thenorelseis inert. They only apply next to anif.
As union branches grow, the conditional chain becomes an unmaintainable nest. A sturdier pattern is to lock a tag field inside each branch so that branches are mutually exclusive by construction:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator is an OpenAPI 3.x extension, not a JSON Schema keyword. Ajv supports it when the discriminator: true option is enabled, but its mapping lives in mapping and is not portable across toolchains. If the schema must work in several validators, prefer the const tag pattern, which depends on no extension at all.
6. Recursive references: trees, linked lists, and files that cite each other
Recursive structures need no special syntax — a $ref back to itself is enough. Validators do not expand references infinitely at compile time; they recurse according to the actual depth of the data, so a few dozen lines describe a tree of any height.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
Three boundaries are worth settling in advance:
- Data depth: the schema imposes no limit, but validation and serialization both consume stack proportional to depth. Public APIs should add a business limit, say eight levels, or a maliciously deep payload can overflow the stack.
- Mutual references: A references B and B references A is legal, but both schemas must be registered first; otherwise the resolution order decides the outcome.
- Nullable branches: if a recursive field may be
null, write"type": ["object", "null"]or wrap it inoneOf.$refdoes not inherit nullability.
7. Cross-file references and bundling: never fetch schemas in production
Cross-file references often "just work" in development because the validator fetches the target by $id over the network. Change the network policy at deploy time and the whole validation chain breaks. The production rule is simple: every referenced schema must be registered in-process or bundled ahead of time.
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);
If order.json has $id: "https://bbdrug.com/schemas/order.json", its "$ref": "address.json" resolves to https://bbdrug.com/schemas/address.json, which matches the registered key. A single differing character yields can't resolve reference.
The three practical approaches compare like this:
| Approach | How | Strength | Cost |
|---|---|---|---|
| Register at runtime | addSchema per file |
No build step, files stay independent | Registration order and keys are manual |
| Bundle at build time | Merge with a ref parser into one file | Zero runtime dependencies | Larger artifact, noisy diffs |
| Async loading | loadSchema + compileAsync |
Handles frequently changing schemas | Network and cache concerns; avoid on critical paths |
For frontend projects, bundling at build time is safer. On the server, registration is enough, provided a test proves every reference resolves. Either way, run compile in CI so reference errors surface before merge rather than on the first production request.
8. Debugging order: resolve, compile, then inspect branches
Nearly every $ref problem can be located in three stages, and the order matters:
| Symptom | Likely cause | Fix |
|---|---|---|
can't resolve reference ... from id ... |
Typo in $id, wrong relative base, target not registered |
Print the $id and the resolved absolute URI, then compare with registered keys |
schema with key or id already exists |
The same $id registered twice, or compile called repeatedly |
Move registration to module init and run it once |
strict mode: unknown keyword: "definitions" |
Mixing draft-07 syntax | Rename to $defs; do not disable strict mode |
Data matches two oneOf branches |
Overlapping branch constraints, no tag field | Add const to each branch, or switch to anyOf |
Constraints inside $defs have no effect |
Nothing references them | Check the reference path; $defs validates nothing by itself |
When debugging, read schemaPath before message: it names the branch and keyword that failed and is far more reliable than English prose. Cache the compiled validator at module scope instead of recompiling per request. For every schema change, cover both a passing and a failing case — testing only the happy path tests nothing. For validator selection and error-message design, see JSON Schema: From Basics to Production; for wiring a schema into type generation, see Two-Way Sync Between Schema and TypeScript Types.
9. Frequently asked questions
Can $ref live next to type or description?
2020-12 allows $ref alongside sibling keywords, and those siblings are evaluated normally. In draft-07 the siblings are ignored and only $ref applies. This is the biggest behavioural difference when the same schema is used across drafts, so confirm which draft your validator supports before migrating.
Should I use $defs or definitions?
Use $defs for new work; it arrived with 2019-09. definitions is not a keyword in 2020-12. A pointer such as $ref: "#/definitions/foo" can still resolve because it is plain JSON Pointer, but strict mode will report an unknown keyword. Mixing both styles leaves a landmine that only explodes in CI.
Why does oneOf reject valid-looking data?
Because the data satisfies two branches at once. oneOf demands exactly one, and a branch that constrains only part of the object is satisfied even when the other branch's fields are absent. Add a const tag to each branch to make them mutually exclusive.
Will recursive references loop forever?
No. Validators do not expand references at compile time; they recurse over the actual data. The real risks are data deep enough to overflow the stack and wrong resolution order between mutually referencing files. A business-level depth limit on recursive fields is more effective than any schema trick.
Can production rely on remote $ref?
Do not. Remote references tie validation availability to the network: domain changes, expired certificates, and internal network isolation all break the chain. Bundle the referenced schemas into the artifact or register them at startup if you want predictable behaviour.
How do I decide between $defs and separate files?
A structure that appears in two places can be copied once; when it appears in a third place, promote it to $defs. Only when another language or another repository must read it does it deserve a standalone file with its own $id.
Why do constraints inside $defs have no effect?
$defs is only a container, and nodes that no $ref points to never participate in validation. A required list at the top of $defs with no incoming reference still lets data pass — that is the specification, not a validator bug.
Should I put additionalProperties: false inside allOf branches?
No. Each branch is evaluated independently, so one branch treats fields declared by another branch as extra. The 2020-12 approach moves the unknown-field policy to the top level and uses unevaluatedProperties: false, evaluated once after all branches.