Schema validation rules¶
Look up exactly which payloads a topic's JSON Schema accepts, which schema documents Narad registers, and which changes between versions it allows.
curl -i -u "$AUTH" -X POST "$NARAD/v1/topics/payments/produce" \
-H "Content-Type: application/json" \
-d '{"amount": 5}'
HTTP/1.1 400 Bad Request
Content-Length: 133
Content-Type: application/json
Date: Mon, 28 Sep 2026 19:31:44 GMT
{"error":"invalid argument: schema: jsonschema validation failed with 'narad://schema/payments/1#'\n- at '': missing property 'id'"}
$NARADis the base URL of any node or of the load balancer, for examplehttp://127.0.0.1:7942.$AUTHisusername:passwordof a user withproduceon the topic.- The topic
paymentswas created with the schema{"type": "object", "required": ["id"]}.
How to add a schema to a topic and change it is in Enforce schemas on a topic. This page is the rulebook behind it.
Validation on produce¶
When a topic has a schema, every produce body must be one JSON text, in valid UTF-8, that the topic's current schema version accepts. Anything else gets 400, and nothing is written. The error message names the failing keyword and where in the payload it failed, and is cut at 2 KiB, so a schema with a huge enum cannot turn one refused produce into a large response. A topic without a schema stores any bytes.
New in v3.1.0: when a full report of every violation would be very large (a megabyte of failing values, or many items each missing a long required list), the payload is checked without building one, and the 400 says so instead of naming a violation: validate a smaller part of the payload to find one. The verdict is the same either way.
What "one JSON text" means:
- Text that is not JSON, binary data, an empty body, trailing data after the value (
{"id":1} {"id":2}), a UTF-8 byte-order mark, a raw control character inside a string, and invalid UTF-8 inside a string all get400. - Any JSON value can be the payload:
[1,2]and"text"are checked the same way objects are. A schema oftrueor{}therefore means "must be JSON" and nothing more. - When an object repeats a key, the last value wins, and that value is the one checked.
- Payloads nest at most 256 levels of objects and arrays (
[[1]]nests two); deeper gets400(payload nests deeper than 256 levels) before validation (from v3.1.0; it was 10,000). The validator builds an error at every level above a failing value, each with a copy of the path to it, so one 20 KB payload nested 9,990 deep made a node hold close to a gigabyte, and real documents nest a handful of levels.
How keywords behave:
- Numbers are checked exactly, not as 64-bit floats:
9007199254740993is odd undermultipleOf: 2,9223372036854775808is overmaximum: 9223372036854775807, and1e400is a validnumber.1.0and1e2are integers;1.5is not. A number whose exponent is beyond ±1000 (1e1001) is refused as an invalid payload. The same bound applies to numbers in a schema at registration. maxLengthandminLengthcount Unicode characters, not bytes.patternandpatternPropertiesuse Go's RE2 syntax and run in linear time; a lookahead or a backreference is refused at registration. Linear time still pays, on every byte of the string, for each partial match the pattern keeps alive and each step it walks to reach them, so registration also refuses a pattern that can cost more than 32 such steps per byte (from v3.1.0): an unanchoreda.{1000}bkeeps a thousand partial matches alive, and(?:\B){1000}xwalks a thousand assertions, either one taking seconds on a 1 MiB string. Anchoring the pattern with^or shortening its repetitions fixes it; an unanchored UUID pattern costs about a dozen.enumof 16 or more values is checked by hash (from v3.1.0), so a large allowlist costs the same per value as a small one. A 45,000-value allowlist of integer IDs used to cost 14 ms per value checked.formatis checked on every draft, the default 2020-12 included. Known formats such asemail,date-time,uuid,ipv4andurirefuse bad values; an unknown format name is ignored.
A batch produce (v3.1.0) checks each message this way before it stores any of them; the first message that fails decides the answer.
Validation capacity¶
New in v3.1.0. Small payloads validate in well under a millisecond and run as they come. A payload above 16 KiB, and any payload on a schema whose validation cost is high (one registered before the rules below existed, one listing more than 1,024 required, dependentRequired or dependencies names, or more than 64 patternProperties keys in all, or one that applies more than 64 subschemas to one value, such as a oneOf or anyOf union of 64 or more branches), is validated under a per-node bound of one validation per CPU core. A produce that finds no free slot within 5 seconds gets 503 (schema: validation capacity busy, retry) and nothing is written: the payload was never checked, so retry it, preferably through another node. narad_schema_validations_in_flight shows how full the bound is.
Schema documents¶
A schema is registered when a topic is created with schema, or changed with schema. The document must meet these rules, or the request gets 400:
- It is a JSON object or
true.false(which would refuse every message),null, strings, numbers and arrays are refused. - It is at most 256 KiB and nests at most 64 levels. A request body over 1 MiB gets
413before this check. $schema, when present, names a built-in draft: 2020-12 (the default), 2019-09, 07, 06 or 04.$refresolves only inside the document:#/$defs/...,#anchorand$dynamicRef. References tofile://,http://,https://or a relative path are refused wherever they sit, including in a$defsentry nothing points to yet. An empty$ref, or an absolute URL equal to the schema's own$id, counts as relative. The error never repeats the reference.$idis accepted and loads nothing.
Three shapes are refused at registration, because of how the validator evaluates them:
- The same value reached by more than one path at one level, such as
"allOf": [{"items": {"$ref": "#"}}, {"items": {"$ref": "#"}}], or ananyOfthat lists the same recursive$reftwice. The validator remembers nothing between paths, so the work grows exponentially with payload depth: a 21-byte payload nested ten deep against four such branches takes over a second. Ordinary recursive schemas, where the recursion goes through different property names, or throughitemsandadditionalProperties, reach each value once and are accepted. The error names the two paths. - One value checked against one subschema through more than 64 paths (from v3.1.0), recursive or not. The validator pays every path in full: an acyclic chain of
$defsentries that each apply the next one twice (throughallOf,anyOforoneOf) reaches its last entry through 2n paths, and 1.4 KB of such a chain took two seconds to check the string"x". Paths are counted on the references as the validator resolves them, whatever their form:#/$defs/...,$anchor,$dynamicAnchor, an$idor draft-04idfragment, a nested resource, a pointer into a member no keyword owns, and every target a$dynamicRefcan pick. A shared definition used under many property names is one path per value, and a union of up to 64 branches that each reference one base is within the limit (a union of 64 or more branches is then validated under the per-node validation capacity bound, whatever the payload's size). The error names the subschema. - A
$refcycle that re-applies a schema to the value being checked, without going into a child value, throughnot,iforoneOf("not": {"$ref": "#"}). The validator fails such a cycle where it first revisits a schema, so under a negation the result would depend on where the cycle was entered. The same cycle throughallOf,anyOf,thenorelseis accepted, because it can only fail that branch, and so is a loop of nothing but$ref, which accepts no value at all.
Compatibility¶
A topic's schema history only grows. Each schema change is checked against the latest version on the cluster leader and stored as the next version number. A version is never overwritten or removed, and a topic holds at most 1000 versions; the next change gets 409.
New in v3.1.0: histories are also bounded in bytes, because every node stores, snapshots and restores them, and a fan-out child keeps its own copy of its parent's. A topic's stored versions may hold at most 4 MiB together, and every schema in the cluster at most 256 MiB, counting each child's copy. A schema change, a create with a schema, or a create-as-child or attach that would adopt a parent's history past either budget gets 409 naming the budget and what is stored. A history already over 4 MiB, stored before the budgets applied, keeps working, but it takes no new version and no new schema-less child, and the 409 says so: smaller versions cannot help, so put the new schema on a new topic. A cluster whose schemas already hold more than 256 MiB takes no new schema version on any topic, a new topic included, until topics are deleted to bring the total under the budget. Schemas are stored compacted (insignificant whitespace removed), so a client's formatting does not reach the history; versions stored before stay as they were.
Four rules about the change request:
- Idempotent. A
schemaequal to the current version (the same JSON value; formatting and key order do not matter) registers nothing and answers200, so a retry after a lost response does not grow the history. - Annotations are not a change (from v3.1.0). A
schemathat differs from the current version only intitle,description,examples,$comment,default,deprecated,readOnlyorwriteOnly, wherever a subschema sits, accepts exactly what the current version accepts: it registers nothing and answers200with the current version. A property nameddescriptionis a property, not an annotation. To change documentation, change it together with a real widening. - Conditional. With
"schema_base_version": N, the change applies only if the current version is exactlyN; otherwise409(schema version conflict) and nothing changes. Without it, the change is checked against whatever version is current when the leader applies it. - Not removable.
"schema": nullgets400. A schema can only be widened, and{}is not a widening of a schema with properties (seepropertiesbelow).
The check enforces one rule: every message the previous version accepted stays valid. It compares the two versions structurally and fails closed: a construct it cannot reason about may only stay exactly as it was, or disappear where removing it can only widen the schema. Anything else gets 400, with a message that names the keyword and where it sits (at /properties/qty: type "integer" no longer allowed).
| Keyword | Allowed change |
|---|---|
type |
Add types to the set; integer may become number; drop the keyword. |
enum, const |
Add values (const may become an enum that contains it); drop. Both on one schema count as their intersection. |
minimum, exclusiveMinimum, maximum, exclusiveMaximum |
Loosen or drop; never add. |
multipleOf |
Change to a divisor of the old value; drop. |
minLength, minItems, minProperties |
Decrease or drop; a new one may appear only as 0. |
maxLength, maxItems, maxProperties |
Increase or drop; never add. |
pattern, format |
Keep identical or drop. |
uniqueItems |
true may become false or absent; never add. |
required |
Remove names; never add. |
properties |
Add optional properties; never remove one. Each existing property is checked with these same rules. A new property whose name matches a previous patternProperties pattern must accept everything that pattern's schema did. |
additionalProperties |
false may open up (to absent, true, or a schema); a schema may only widen; a closed model or a schema may not appear where there was none. |
items |
Widen or drop; never add. The array form (tuples) is not supported. |
anyOf |
Every old branch must be covered by some new branch. |
allOf |
Every new branch must be implied by some old branch. |
$ref |
Only #/... pointers into the same document, with no sibling keywords (annotations, $schema and a root $id aside). Both sides are resolved before they are compared. Recursive schemas are compared the way recursive types are: a pair already being compared further up counts as compatible. A loop of nothing but $ref accepts nothing, so anything may replace it, and it may replace nothing. |
$schema |
Must not change. |
The other keywords:
oneOf,not,if/then/else,contains,propertyNames,dependentRequired,dependentSchemasandpatternPropertiesmay stay identical or be removed.patternPropertiesmay be removed only whileadditionalPropertiesstays open, andprefixItemsonly together withitems. "Identical" follows$ref: anotthat points at#/$defs/xcounts as changed whenxchanges.- A nested schema may always become
trueor{}, which accepts everything at its position. - A keyword the schema's draft does not define (
constbefore draft-06,if/then/elsebefore draft-07,prefixItemsbefore 2020-12,additionalItemsfrom 2020-12 on) is an annotation to the validator, and the check treats it as one too. unevaluatedProperties,unevaluatedItemsand$dynamicRefare accepted only in a part of the schema that is unchanged byte for byte.title,description,default,examplesand$defscan change freely.
Two limits of the check:
- An optional property added under an open content model is allowed, even though a message that already carried that key with another type was valid before. This is the one deliberate exception. Use
"additionalProperties": falsewhen you need exact semantics. - Each keyword is compared only with the same keyword in the previous version. A change that is safe only because of a different keyword (dropping
const: 5forminimum: 3) is refused: widen the schema keyword by keyword.
Fan-out children¶
A child created under, or attached to, a parent with a schema adopts the parent's whole history: the same version numbers and the same documents. While it is attached, its parent manages it: changing the child's schema gets 409, and every change to the parent's schema reaches the child in the same metadata write. After a detach, the child keeps the history it has and manages it again.
- A child can be attached, or attached again, only to a parent whose history is identical to its own, version for version. (v3.1.0) Once every member runs this release, identical means the same JSON value: key order, whitespace and the spelling of a number (
10,1e1) do not matter, so a history copied from what the API serves matches one stored with the client's formatting. Before that, the stored bytes must match. - A child with a schema cannot go under a parent without one. A child without a schema adopts the parent's on attach.
- To link a child whose history has drifted, first make the parent's history, or the detached child's, the same as the other.
Fan-out itself is described in Fan out and delay messages.
When a version takes effect¶
A schema change answers once the new version is applied on the leader and on the node that took the request, so a read or a produce sent to that same node afterwards sees the new version. Every other node applies it within replication latency, usually milliseconds, and checks the next produce it receives against it. No restart or cache expiry is involved. A produce that reaches one of those other nodes inside that window is checked against the previous version.
Adding partitions, or any other change to a topic, leaves its schema alone. Deleting a topic deletes its history; a topic created again under the same name starts with no schema, or at version 1 with the schema it is created with.