🍱 Lunchbox Hands

json

Generating JSON Schema From a Sample: What Inference Can't Know

A schema inferred from one JSON sample encodes only what that sample happened to contain: every key becomes required, null becomes a type, a status string is not an enum, and an empty array accepts anything. What our draft-07 generator emits in each case, validated against mutated payloads with Ajv 8 — including the overlapping oneOf trap that makes a sample fail its own schema.

You paste an API response into a JSON Schema generator, get a tidy draft-07 document back, wire it into your validator, and the first payload that differs from the sample — a price with cents, a field the API omits for new users, a status you had never seen — gets rejected. Or worse, sails through. The generator did not malfunction. A schema inferred from a sample can only encode facts the sample exhibited, and a single JSON document exhibits almost none of the facts a schema exists to enforce. It cannot see that a key is sometimes absent, that a string has three legal values, or what belongs inside an array that arrived empty.

This post takes one sample through our JSON Schema generator, shows what it emits for each blind spot, and validates that output against mutated payloads with Ajv 8.20 — including one case where our output is wrong rather than merely incomplete. The companion post, JSON to Pydantic: how one sample lies, covers the same problem from the Python side; this one is about the JSON Schema keywords themselves, and which draft you are targeting.

What the generator actually does

Our tool’s inference is short enough to describe in full. It emits $schema: "http://json-schema.org/draft-07/schema#" — the $id of the draft-07 meta-schema — and then walks the value:

Value in sampleEmitted schema
null{"type": "null"}
true / false{"type": "boolean"}
number with zero fractional part{"type": "integer"}
any other number{"type": "number"}
string{"type": "string"} — never a format, never an enum
objecttype: object, one properties entry per key, every key listed in required
empty array{"type": "array", "items": {}}
array, all items infer identicallyitems is that one schema
array, items infer differentlymerged: integer+number → number, object shapes → one object with required = keys present in every element, otherwise items: {"anyOf": [...]}

It never emits additionalProperties, enum, format, minimum, minLength, or any other constraint. Everything below follows from that table.

The sample

{
  "id": 412,
  "name": "ada",
  "status": "active",
  "price": 12,
  "avatarUrl": null,
  "tags": [],
  "createdAt": "2026-09-03T09:30:00Z"
}

Paste that in and this is the output, verbatim:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "name": { "type": "string" },
    "status": { "type": "string" },
    "price": { "type": "integer" },
    "avatarUrl": { "type": "null" },
    "tags": { "type": "array", "items": {} },
    "createdAt": { "type": "string" }
  },
  "required": ["id", "name", "status", "price", "avatarUrl", "tags", "createdAt"]
}

The sample validates against it, of course. The interesting question is what else does. I compiled it with Ajv 8.20 (default draft-07 mode) and threw mutations at it:

MutationResult
Add "extra": truevalid
"price": 12.5invalid — must be integer
"price": 12.0valid
"status": "banana", "createdAt": "not a date"valid
"avatarUrl": "https://x/y.png"invalid — must be null
Omit avatarUrlinvalid — must have required property 'avatarUrl'
"tags": [1, {"x": null}, false]valid

Every row is the schema being faithful to the sample and wrong about the API. Take them one keyword at a time.

required: the sample proves presence, not necessity

JSON Schema’s default runs the other way from what a generator does. Per the draft-07 validation spec, §6.5.3: “Omitting this keyword has the same behavior as an empty array” — and the reference docs put it plainly: “By default, the properties defined by the properties keyword are not required.” (Understanding JSON Schema: object)

A generator has to pick one of two lies. Listing nothing in required means the schema never catches a missing field; listing everything means the first payload that omits an optional key fails. Ours lists everything — deleting a name from an array is easier than remembering to add one — but it is still a guess. Nothing in "avatarUrl": null says whether the key can also be absent. Present-but-null and absent are different facts in JSON Schema: required governs the second, type governs the first.

null becomes a type, not a modifier

Our tool sees null and emits {"type": "null"}, which is exactly right for the sample and useless as a schema: it says the field must always be null. The moment a real avatar URL arrives, validation fails with must be null.

The fix is the type array form. From the type reference: “When type is used as an array, it contains more than one string specifying the types mentioned above. In this case, the instance data is valid if it matches any of the given types.” So "avatarUrl": {"type": ["string", "null"]} is what you meant. The generator cannot write that line because the string half of it never appeared in the sample. This is the schema-level version of the Pydantic post’s Any | None: a null-only field is a confession, not a type.

integer vs number: the spec is kinder than you expect

JSON has one number type; JSON Schema has two, and the boundary is defined by value, not syntax. Draft-07 §6.1.1: "integer" “matches any number with a zero fractional part.” The 2020-12 validation spec uses the same words, and the numeric reference confirms it with an example: 1.0 is valid against {"type": "integer"}. Ajv agrees — ajv.validate({type: 'integer'}, 1.0) is true.

That makes the JSON Schema trap narrower than the Pydantic one: 12.0 is an integer by definition, so the only payload that breaks an inferred integer is one with a genuinely non-zero fraction — 12.5 — and that one breaks it hard. (The generator never sees the decimal point anyway: JSON.parse("1.0") yields the number 1, so {"n": 1.0} and {"n": 1} produce identical schemas.)

The rule is the same as the Python post’s: if a field is money, a measurement, a ratio, or anything an API might one day serve with a fraction, widen integer to number before you ship. An integer in generated output is the tool reporting what it saw once.

enum: "status": "active" is a string, full stop

“The enum keyword is used to restrict a value to a fixed set of values.” (enum reference) It is the single most useful constraint in a real API schema and the single least inferrable: one sample contains one value, and one value is not a set. Our tool emits {"type": "string"} for status, so "banana" validates. No sample-based generator can do better without you telling it the closed set — even an array of a thousand records only proves which values have appeared, not which are legal.

format: we do not infer it, and by default it would not validate anyway

"createdAt": "2026-09-03T09:30:00Z" looks like an RFC 3339 timestamp to a human. Our tool emits {"type": "string"} and nothing else — no "format": "date-time" — so "not a date" validates. That is deliberate: a string that looks like a date might be an opaque ID with an unlucky shape, and a wrong format is worse than a missing one.

But even if we did infer it, format is not what most people assume. The 2020-12 validation spec’s format section is explicit: “The value of format MUST be collected as an annotation” and implementations that also assert it “MUST provide options to enable and disable such evaluation and MUST be disabled by default.” The 2019-09 release notes explain why: “By default, format is no longer an assertion. This has been done because the inconsistent implementation of format as an assertion has been an endless source of surprising problems for schema authors.” Draft-07 was looser — implementations “MAY support the format keyword as a validation assertion” — which is precisely the inconsistency 2019-09 was fixing.

Validators add their own layer. Ajv 8 ships with no formats: “From version 7 Ajv does not include formats defined by JSON Schema specification — these and several other formats are provided by ajv-formats plugin.” (Ajv: formats) In its default strict mode, unknown formats throw at compile time — I hit exactly that compiling {"type": "string", "format": "date-time"} without ajv-formats. So a hand-added format is three decisions — the keyword, the validator’s plugin, and its assertion switch — not a one-word annotation. The formats defined in 2020-12 §7.3 include date-time, date, email, hostname, ipv4/ipv6, uri, uuid, json-pointer, and regex; uuid only arrived in 2019-09, so a draft-07 validator has no obligation to know it.

additionalProperties and items: {}: the schema that accepts everything

Two of the “valid” rows above are the same mechanism. Draft-07 §6.5.6: omitting additionalProperties “has the same behavior as an empty schema” — and an empty schema matches every value. That is why "extra": true passes. Our tool never emits additionalProperties: false, and it should not by default: an API that adds a field would start failing validation for clients that could have ignored it. But while you are still learning an API’s shape, false is the setting that turns every field your sample missed into an error instead of silent acceptance.

"tags": [] is the same empty schema in array form. The generator has zero evidence about element type, so it emits items: {}, and [1, {"x": null}, false] validates. An empty object gets "properties": {} with the same effect. Both are the tool saying “you know something I don’t” — resolve them or they will validate garbage forever.

Heterogeneous arrays: the oneOf trap

Everything above is inference being honest about missing evidence. This section is about a way inference can be actively wrong. The obvious thing to do when array elements infer to different schemas is to list them under oneOf — and oneOf means exactly one. Draft-07 §6.7.3: “An instance validates successfully against this keyword if it validates successfully against exactly one schema.” The combining reference has the canonical failing example: a oneOf of multipleOf: 5 and multipleOf: 3 rejects 15 because it matches both.

Naive branches overlap in two common cases. First, mixed numerics:

[1, 2.5]
{ "type": "array", "items": { "oneOf": [{ "type": "integer" }, { "type": "number" }] } }

1 is an integer and a number, so it matches both branches, and Ajv rejects the very sample the schema was generated from: data/0 must match exactly one schema in oneOf. [2.5] alone passes; [1, 2] fails on every element.

Second, arrays of objects whose keys differ:

[{ "a": 1 }, { "a": 2, "b": 3 }]

If each shape becomes its own oneOf branch, the first branch requires only a and — because additionalProperties defaults to open — is perfectly happy with an object that also has b. So {"a": 2, "b": 3} matches both branches, and again the sample fails its own schema: data/1 must match exactly one schema in oneOf.

Honest disclosure: this is exactly what our generator emitted until this post was fact-checked against it. It now merges instead, and the outputs below are its real output:

[1, 2.5]            → { "type": "array", "items": { "type": "number" } }
[{"a":1},{"a":2,"b":3}] → { "type": "array", "items": { "type": "object",
                          "properties": { "a": { "type": "integer" }, "b": { "type": "integer" } },
                          "required": ["a"] } }
[1, "two"]          → { "type": "array", "items": { "anyOf": [{ "type": "integer" }, { "type": "string" }] } }

integer and number collapse to number; object shapes merge into one schema whose required is the intersection of keys — the same rule our JSON to Pydantic, JSON to Zod, and JSON to TypeScript converters apply when a key is missing from some elements; and genuinely distinct types fall back to anyOf, which is satisfied by at least one match and so can never reject the sample. If you are using a different generator, or an older schema from ours, check every oneOf for overlap before trusting it — the fix is always either a merged schema or anyOf.

Which draft, and why it matters for inferred output

Our tool emits draft-07. The current draft is 2020-12, with 2019-09 between them, and the $schema values differ — draft-07 is http://json-schema.org/draft-07/schema#; per the specification links, the later drafts use https://json-schema.org/draft/2019-09/schema and https://json-schema.org/draft/2020-12/schema. Three differences touch inferred output:

Concerndraft-072019-092020-12
Tuple arraysitems: [s1, s2] + additionalItemssameprefixItems: [s1, s2] + items; array-form items is invalid
formatimplementations MAY assertannotation by defaultsplit into format-annotation (default) and format-assertion vocabularies
Seeing across subschemasadditionalProperties onlyadds unevaluatedPropertiessame

The 2020-12 release notes state the array change directly: “The items and additionalItems keywords have been replaced with prefixItems and items.” Ajv enforces it both ways: Ajv2020 refuses to compile items: [{type: "integer"}] (data/items must be object,boolean), and the default draft-07 Ajv rejects prefixItems as an unknown keyword.

The upshot for our output: because the generator only ever emits items as a single schema, never the tuple form, everything it produces is valid under all three drafts — swap the $schema line and nothing breaks. If you want a tuple ([lat, lng] pairs, say), no sample-based generator can tell a positional array from a homogeneous one; prefixItems is yours to write.

Before and after

The generated schema, hardened with what the sample could not say — verified with Ajv 8.20 plus ajv-formats 3.0.1:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "id": { "type": "integer" },
    "name": { "type": "string", "minLength": 1 },
    "status": { "enum": ["active", "suspended", "deleted"] },
    "price": { "type": "number", "minimum": 0 },
    "avatarUrl": { "type": ["string", "null"], "format": "uri" },
    "tags": { "type": "array", "items": { "type": "string" } },
    "createdAt": { "type": "string", "format": "date-time" }
  },
  "required": ["id", "name", "status", "price", "createdAt"]
}
PayloadGenerated schemaHardened schema
Original samplevalidvalid
price: 12.5, avatarUrl a URL, tags omittedinvalid (3 reasons)valid
status: "banana", createdAt: "not a date"validinvalid — enum, date-time
"extra": truevalidinvalid — additional properties
tags: [1]validinvalid — tags/0 must be string

Every edit is a fact about the API that no number of samples could have supplied with certainty. The generator’s job was the skeleton — types, nesting, key names, no typos. Yours is the constraints.

The checklist

Before an inferred schema touches a validator:

  1. Prune required to the keys the API actually guarantees.
  2. Turn every {"type": "null"} into ["<real type>", "null"].
  3. Widen integer to number wherever a fraction is conceivable.
  4. Replace {"type": "string"} with enum for closed sets, and add format only if your validator asserts it.
  5. Resolve every items: {} and properties: {}.
  6. Decide additionalProperties on purpose — false while exploring, open once the shape is settled.
  7. Inspect every oneOf for overlapping branches; prefer a merged schema or anyOf.
  8. Pick your draft, and if it is 2020-12, know that items can no longer be an array.

Paste a payload into the JSON Schema generator — it runs entirely in your browser — and treat the output as a typed skeleton to constrain, not a contract to enforce. Once you have tightened it, run real payloads back through the JSON Schema validator: it checks a document against the schema and reports each failure with its instance path and line number, which is how you confirm the constraints you added above actually reject the payloads you meant them to. If the same sample needs to become code as well as schema, the JSON to Pydantic, JSON to Zod, JSON to TypeScript, and JSON to Go converters run the same kind of inference with the same blind spots, and the Pydantic post and Go post walk through hardening those, while JSON to C#, Java and GraphQL runs one sample through four target languages at once.