Writing a JSON Schema from a sample, and knowing where the sample lies
A schema written by hand drifts. The API grows a field, the schema does not, and the validator starts rejecting payloads nobody touched. Reading the schema off a real payload fixes the transcription problem, but a sample only shows what happened once, not what is allowed. The sections below cover what each JSON value turns into, why the four drafts differ in ways validators punish, how required gets decided, and which lines deserve a second look before the file goes near CI.
What each JSON value turns into
JSON has six value kinds and the schema vocabulary has a keyword for each. Strings become {"type": "string"}, with a format attached when the value matches a known pattern. A whole number becomes integer when the toggle is on, a decimal becomes number, and true or false becomes boolean. A bare null is typed null on its own, or joins the type list when the same key holds a real value somewhere else in the sample. Objects get properties and a required array. Arrays get an items schema merged from every entry in the list, which is the part most generators skip.
Take the two order lines from the loaded sample:
"items": [{ "sku": "TX-CABLE-2M", "qty": 2, "unit_price": 19.75, "gift": true },{ "sku": "TX-DOCK-USB4", "qty": 1, "unit_price": 110 }]Both entries are folded into one item schema before anything is written. unit_price holds a decimal in one and a whole number in the other, so the merged type is number. gift appears in one record out of two, so under the default policy the key stays out of required:
"items": {"type": "array","items": {"type": "object","properties": {"sku": { "type": "string" },"qty": { "type": "integer" },"unit_price": { "type": "number" },"gift": { "type": "boolean" }},"required": ["sku", "qty", "unit_price"]}}The ledger under the editors records both decisions with the counts behind them, so you know which lines came from evidence and which from a single occurrence.
Four drafts, and the differences a validator will punish
The $schema line is not decoration. Validators read the draft from it and switch keyword behaviour to match, so a schema written for one draft and declared as another fails in ways the error messages rarely explain. The generator only writes keywords the chosen draft defines:
- Draft 2020-12 is the current spec and the one OpenAPI 3.1 adopted. Its URI uses
httpswith no trailing#. Tuple validation moved toprefixItems, so a draft-07 tuple schema stops validating under this dialect. Ajv 8 needs theajv/dist/2020entry point for this draft. - Draft 2019-09 introduced
unevaluatedProperties,$defs, anddependentRequired. Its keyword set is a near match for 2020-12, and the URI format is the same. - Draft 07 is what Ajv 8 defaults to and what most editor tooling assumes when no draft is set. Definitions live under
definitions, and$commentandif/then/elsearrived here. Pick this when the consuming code is more than two years old or you are not sure. - Draft 04 still turns up in older Java validators and in Swagger 2 tooling. The identifier keyword is
idrather than$id,examplesdoes not exist, andexclusiveMinimumis a boolean rather than a number. Choose it and the generator dropsexamplesand writesidwithout being told.
One trap worth naming. OpenAPI 3.0 uses a subset of draft 05 with its own nullable: true keyword instead of "type": ["string", "null"]. A schema from this page with a null in the type list will fail OpenAPI 3.0 linting. OpenAPI 3.1 accepts the type list as written.
Required is a policy, and the sample cannot settle it for you
Nothing in a JSON document says which keys are mandatory. A key present in the payload was present that time. The three policies in the setup strip are three different answers to the same question:
- Present in every record. The default. A key becomes required when every object at the same path carries it. With one record at a path, every key qualifies, which is why a single response makes for a weaker sample than a list of five.
- Every key seen. Any key that appeared once is required everywhere. Useful for a config file you control, where a missing key is a mistake rather than a variation. Dangerous for a webhook, since the first payload with an omitted optional field gets rejected.
- Nothing required. The schema describes shape only. Pick this when you plan to hand edit the
requiredarrays anyway and want a clean starting point.
Whichever policy is on, the ledger names the count behind each decision, such as gift present in 1 of 2 records. A required key with a low count is the line to question first.
integer against number, and 110 against 110.0
JSON has one numeric type. The schema vocabulary has two, and the gap between them causes most of the surprise rejections people bring to a schema. A price field holding 110 reads as an integer, and if every record in the sample happens to hold a whole number, the schema says integer and the first payload with 19.75 fails. The generator widens to number the moment a decimal shows up anywhere at the same path, and the ledger marks the row as widened so you know the evidence was mixed.
The reverse direction is the safer edit. A qty or a version field is meant to be whole, and integer catches a 1.5 that would otherwise slip through. Money is the case to think about: if the API sends cents as a whole number, keep integer. If it sends dollars with a decimal, switch the toggle off for that field or edit the type in the right hand box before running the check.
Formats are annotations until the validator is told otherwise
With detection on, a string matching one of eight patterns gets a format keyword: date-time, date, time, email, uuid, uri, ipv4 and ipv6. The pattern match is strict, so 2026-08-14T09:12:44Z is a date-time while an order reference like 2024-1234 is left alone. When strings at the same path disagree, no format is written and the ledger says how many matched.
What the keyword does at validation time depends on the validator. In draft 2019-09 and 2020-12, format is an annotation by default and validators are told to ignore it unless format assertion is switched on. Ajv refuses to compile a schema carrying format at all unless ajv-formats is installed. Python's jsonschema needs a FormatChecker passed in. The checker on this page does test formats, and labels those errors so you know they might pass elsewhere.
Nulls, empty arrays, and other holes in the evidence
Some values carry less information than they appear to. A key holding null in every record has no known real type, so the schema says null and the ledger flags the row amber. Fix it by pasting a sample where the field is filled, or by editing the type list by hand. An empty array tells the generator the key is a list and nothing else, so items is left off and any element shape will pass. A list mixing strings and numbers comes through as a type list, and a list mixing objects with scalars comes through as anyOf, which validates but is rarely what the API intends.
Locking additionalProperties, and when not to
"additionalProperties": false turns every object into a closed shape. Any key not listed under properties fails. For a config file your own code reads, this is the right default, since a typo in a key name becomes a validation error instead of a silently ignored setting. For a payload someone else sends, it is a time bomb. Third party APIs add fields without a version bump, and a closed schema turns each addition into an outage.
The toggle applies to every object in the tree at once. If you want the root closed and nested objects open, generate with the toggle on and delete the keyword from the nested blocks in the editor. Under draft 2019-09 and later, unevaluatedProperties does the same job while still allowing keys contributed by allOf branches, which matters once the schema starts composing other schemas.
Testing the schema before it ships
The checker at the bottom of the bench validates a second payload against whatever is in the schema box, including your edits. The failing sample button loads an order with five planted problems: a missing currency, a total sent as a string, a fractional qty, a filled in phone where the sample only ever showed null, and a coupon key the schema has never seen. Run it with the lock toggle off and the extra key passes, run it with the lock on and the checker reports it. The phone error is the null trap from the section above, caught before it reached production. That round trip is the fastest way to feel what each toggle does.
The checker covers type, properties, required, additionalProperties, items, enum, anyOf, and the eight formats above. It is not a full implementation of any draft. Pass the final schema through Ajv, Python's jsonschema, or your platform's validator of record before wiring it into a pipeline.
Where this stops
- No enums. A
statusfield holding"shipped"in every record is typedstring. Two samples cannot show the full set of allowed values, and a wrongenumis worse than none. - No ranges or patterns.
minimum,maxLength, andpatternare yours to add. The sample shows one value, not the boundary. - No
$refor$defs. Two objects with identical shapes are written out twice. The output stays copy-paste friendly at the cost of duplication in large schemas. - Tuples read as arrays. A fixed
[lat, lng]pair comes through as{"type": "array", "items": {"type": "number"}}with no length constraint, since nothing in JSON marks a fixed position. - Comments and trailing commas fail the parse. The reader is
JSON.parse, so JSON5 and JSONC files need cleaning first. - Large payloads slow the tab down. Everything runs on the main thread. A few megabytes is fine. A hundred megabyte export is not, and a trimmed slice of it produces the same schema.
Nothing you paste leaves the page. Parsing, inference and the checker all run in JavaScript inside your browser, so a response carrying customer data or a bearer token stays on your machine. Load the page once, drop the connection, and the bench keeps working.
