JSON null vs [] vs {} vs Missing Field: Modeling Empty Collections Correctly
Last updated:
A single JSON field can be absent in four different ways — the literal null, an empty array [], an empty object {}, or a missing key. Each one carries a different meaning to a strict reader, and the choice ripples through PATCH semantics, OpenAPI schemas, TypeScript types, and the database row behind the response. Most production APIs are inconsistent here, and most client bugs in the boundary layer come from the gap between what the server emits and what the schema documents. This guide is a decision-and-reference page: when each of the four representations is the right answer, what RFC 7396 JSON Merge Patch and the JSON:API spec say about them, how OpenAPI 3.1 expresses nullability, and how to make the choice survive round-tripping through SQL.
Tracking down a payload where one field is null in some responses, an empty array in others, and missing in a third? Paste both responses into Jsonic's JSON Validator — it surfaces shape differences side by side so the inconsistency becomes obvious.
Four states for a list field: null, [], missing — each means something different
Take a single field — tags on a blog post. The same logical answer ("no tags") can be encoded four ways, and a strict reader will treat each one differently. Here are the same conceptual post in all four representations:
// 1. Populated — the normal case
{ "id": 42, "title": "Hello", "tags": ["intro", "demo"] }
// 2. Empty array — the post exists and we looked; zero tags
{ "id": 42, "title": "Hello", "tags": [] }
// 3. Explicit null — the post has no tags assigned at all (intentional)
{ "id": 42, "title": "Hello", "tags": null }
// 4. Missing field — server did not include tags in this response
{ "id": 42, "title": "Hello" }A naive client treats 2, 3, and 4 as "no tags" and moves on. A strict client treats them as three different signals. Iterating post.tags.map(...) works on representation 2, throws on 3 (cannot read map of null), and throws on 4 (cannot read map of undefined). Each runtime, each language, and each schema validator handles them differently — which is exactly why the choice has to be a deliberate API design decision, not an accident of whatever the database returned.
The same logic applies to objects: an empty object {} (the field exists and contains zero keys), an explicit null (the field is intentionally not set), and a missing field are three different signals on the wire. For string-valued fields the choice reduces to three (empty string "", null, missing) but the same rules apply.
Semantic meaning: "no answer" vs "empty answer" vs "field doesn't apply"
The four representations line up with three semantic categories, and getting the mapping right is the whole game. Think about what each one says to a careful reader:
[]or{}— "I looked, there are zero items." The server has considered the question and the answer is an empty collection. A blog post with no tags. A user with no notifications. The shape matches the populated case so client code does not branch.null— "I considered this and there is no value yet." The field exists in the data model, the server explicitly decided no value, and the consumer should treat this as a meaningful "unset" state. A user's preferred-language preference that has not been chosen. A computed score that has not been calculated yet.- Missing field — "this field does not apply to this response." The server is not including this field at all. Either it does not apply to this resource kind, the client did not ask for it (sparse fieldsets), or the value would be a documented default that the client can infer.
Confusing these is where bugs happen. If your API uses null for both "user has no nickname" (state choice) and "we did not load the nickname field in this query" (absence-of-data choice), clients cannot tell whether to write null back on PATCH or to leave the field alone. Pick one meaning per field and stick to it across every endpoint that touches the field.
Tombstones: using null vs missing for deletes in PATCH (RFC 7396 JSON Merge Patch)
RFC 7396 JSON Merge Patch is the simplest partial-update protocol on the web: the client sends a JSON object, and for each key, the value in the patch replaces the value in the target. The wrinkle is that null in the patch is the tombstone — it deletes the field from the target rather than setting it to null.
// Target resource
{ "name": "Ada", "nickname": "Countess", "tags": ["math"] }
// PATCH body — null deletes, missing leaves alone, value replaces
{
"nickname": null, // delete the nickname field
"tags": ["math", "logic"] // replace tags
// name is missing — leave it alone
}
// Resulting resource
{ "name": "Ada", "tags": ["math", "logic"] }This overload of null is the reason mixed conventions break. If your normal GET response can return "nickname": null to mean "the user has no nickname set", then a round-trip through PATCH erases the field instead of preserving it. You have two options:
- Stay on Merge Patch but ban null in responses — represent "no nickname" by omitting the field, and reserve null for the patch-delete meaning. The response surface gets smaller but the round-trip is safe.
- Switch to RFC 6902 JSON Patch — which uses explicit operation objects (
add,remove,replace) and never overloadsnull. More verbose, but unambiguous.
See the JSON Merge Patch guide for the full operation table and how to handle nested deletes. The summary: Merge Patch is great for flat resources where deletes are rare; JSON Patch is the right pick when your API leans heavily on partial updates.
Strict vs lax APIs: requiring arrays always be present (never null)
A strict API contract guarantees that array-typed fields are always present and always actual arrays — never null, never missing. The consumer can write response.tags.map(...) without a guard and never have it throw. A lax API permits any of the three absence forms and pushes the burden onto every consumer to coalesce. Most public APIs end up lax through accretion; well-designed APIs are strict by default and document every exception.
// Strict — array fields are always present, always arrays
GET /posts/42
{
"id": 42,
"title": "Hello",
"tags": [], // never null, never missing
"comments": [], // ditto
"author": { "id": 7, "name": "Ada" }
}
// Lax — same endpoint, different "no items" representations
GET /posts/42
{
"id": 42,
"title": "Hello",
"tags": null, // ?? client must handle
// comments: missing — client must handle
}The strict style has two big wins. Client code is simpler — no defensive ?? [] or tags || [] patterns scattered everywhere. And schema validation is cleaner — every field is non-nullable and required, so a single rule catches drift. The cost is that the server must coalesce nullable database columns into empty arrays at serialization time, and you give up the ability to distinguish "no tags" from "tags not loaded".
If you need that distinction, the right answer is usually a separate flag rather than overloading the array field. A response envelope with "loaded": ["tags", "comments"] lets the client know which fields are authoritative, without polluting the field types themselves.
OpenAPI 3.1 modeling: nullable, required, default (the three knobs)
OpenAPI 3.1 (the dialect aligned with JSON Schema 2020-12) gives you three knobs to express absence semantics: the type array, the required list on the parent schema, and the default value. The older OpenAPI 3.0 dialect used a separate nullable: true keyword which 3.1 removes — if you copy 3.0 schemas forward, your nullable fields silently become non-nullable.
# OpenAPI 3.1 — nullable array, required to be present
type: object
required: [tags]
properties:
tags:
type: [array, "null"] # may be array or null, must be present
items: { type: string }
default: []
# OpenAPI 3.1 — optional array, when present must be a non-null array
type: object
# tags is not in required
properties:
tags:
type: array
items: { type: string }
# OpenAPI 3.0 (legacy — note the keyword difference)
type: object
required: [tags]
properties:
tags:
type: array
items: { type: string }
nullable: true # 3.0 only — gone in 3.1The three knobs combine into eight meaningful states. Most APIs end up using two or three of them and lock in the convention with a JSON Schema validator at the server boundary. See the JSON Schema nullable guide for the full migration table from 3.0 to 3.1, and how Ajv and other validators handle each form.
A common mistake is to set default: [] and assume the server emits it automatically — OpenAPI defaults are descriptive, not prescriptive. They tell the client what to assume when the value is missing, but they do not cause the server to insert the value. If you want the server to always emit [], do it in the serializer.
TypeScript implications: optional?, undefined, null
TypeScript distinguishes three states that JSON only partially encodes: present and non-null (string), present and null (string | null), and absent (string | undefined or optional ?). The catch is that JSON has no undefined — JSON.stringify drops keys whose values are undefined, mapping the TypeScript "absent" state to the JSON "missing key" state. null round-trips faithfully.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true // enforces the JSON distinction
}
}
// Without exactOptionalPropertyTypes — sloppy
type Post = {
id: number
tags?: string[] // tags may be absent OR explicitly undefined
}
const a: Post = { id: 1, tags: undefined } // allowed — round-trips as missing
// With exactOptionalPropertyTypes — strict
type Post = {
id: number
tags?: string[] // tags may be absent, but cannot be undefined
}
const a: Post = { id: 1, tags: undefined } // error
const b: Post = { id: 1 } // ok — absent
// Modeling all three states explicitly
type Post = {
id: number
tags?: string[] | null // absent OR null OR array
}The pragmatic rule is to enable exactOptionalPropertyTypes in any new project and write your types to mirror the wire format exactly. If a field can be missing in JSON, mark it optional. If it can be null in JSON, union it with null. If it can be both, do both. Each ? and each | null in your types should correspond to a real possibility in the JSON your server emits — not a defensive hedge.
For input validation at the boundary, libraries like Zod, Valibot, and Ajv each have their own handling of "optional vs nullable vs both". Pick one and stay consistent. Mixing validators in the same codebase produces types that disagree about whether undefined is a valid input.
Database mapping: SQL NULL, JSON NULL, JSON Schema nullable
The database is where most absence-semantics bugs originate. A nullable SQL column has two states (a value or NULL), which maps to JSON cleanly only if you collapse "missing" and "null" into the same thing. If your API contract distinguishes them, you need either a different column shape or a clear serialization rule.
-- Postgres table
CREATE TABLE posts (
id bigint PRIMARY KEY,
title text NOT NULL,
nickname text, -- nullable scalar
tags text[] DEFAULT '{}' NOT NULL, -- non-null array with default
metadata jsonb -- can be SQL NULL, JSON null, or any JSON value
);
-- Three distinct states encoded in the metadata column
INSERT INTO posts (id, title, metadata) VALUES
(1, 'a', NULL), -- SQL NULL — column has no value at all
(2, 'b', 'null'), -- JSON null literal stored as JSONB
(3, 'c', '{}'); -- empty JSON object
SELECT id, metadata, metadata IS NULL AS sql_null,
metadata = 'null'::jsonb AS json_null
FROM posts;
-- id | metadata | sql_null | json_null
-- ----+----------+----------+-----------
-- 1 | | t |
-- 2 | null | f | t
-- 3 | {} | f | fThe pattern that scales: make scalar columns NOT NULL with a sensible default and reserve nullability for fields where "no value" carries application meaning. For array fields, use NOT NULL DEFAULT '{}' (Postgres) or its equivalent — then your API can guarantee tags is always an array and clients never need to coalesce.
When the application genuinely needs three states for a field (SQL NULL = missing, JSON null = null, value = value), a jsonb column is the cleanest encoding. A scalar column cannot carry the distinction without an additional flag column, which usually means the design is fighting the schema.
Choosing a convention: pick once and document
The single most useful rule for a JSON API is: pick a per-field convention, write it down, and enforce it. Inconsistency costs every client integrator their own defensive coalesce in their own language and framework — a tax that compounds every time a new client integrates with you. Consistency costs one paragraph in the schema document.
Here is a decision matrix for the common cases, in declining order of frequency:
| Field shape | Default convention | When to deviate |
|---|---|---|
| Array (collection of items) | [] always present | Use null when "not yet loaded" must be distinct from "zero items" |
| Object (sub-resource) | null when no related resource, present object when one exists | Use missing when the sub-resource is loaded on demand (sparse fieldsets) |
| String (user-entered field) | "" when blank but submitted, missing when never asked | Use null when "blank by choice" differs from "never set" |
| Number (count, score) | 0 when computed and zero, null when not yet computed | Use missing if the field is computed lazily and clients can request it |
| Boolean (flag) | false when unset, true when set | Use null when "user has not chosen" differs from "user said no" |
| Optional metadata | Missing when not applicable | Use null if a PATCH consumer needs to clear it |
Whatever you pick, encode the decision in your OpenAPI schema, validate every response against it in integration tests, and treat schema drift as a P1 bug. The schema is the contract — if the server emits something the schema does not allow, the bug is in the server, not in the schema. For broader API design principles see the JSON API design guide, and for the specific case of nullable fields see JSON null handling.
Sibling concerns worth pinning down in the same style guide: UTF-8 encoding (see UTF-8 encoding), large-integer handling (see BigInt strategies), and field naming (see Key naming). These four — empty collections, encoding, big numbers, naming — are the four decisions that almost every public JSON API gets inconsistent first.
Key terms
- null
- The JSON literal value defined by RFC 8259 — represents an explicit "no value" for a present field. Distinct from a missing key (which is absent entirely) and from an empty collection (which has zero items).
- missing field
- A key that is not present at all in a JSON object. JSON itself has no concept of "missing" — that distinction lives in the schema layer (JSON Schema
required, OpenAPIrequired, TypeScript optional?). - empty collection
- An array
[]with zero items or an object{}with zero keys. The field is present and the answer is "the collection exists and has nothing in it" — different from null and from missing. - JSON Merge Patch (RFC 7396)
- A partial-update protocol where the client sends a JSON object and each key replaces the same key on the target. The special rule:
nullin the patch deletes the field from the target instead of setting it to null. - nullable (OpenAPI / JSON Schema)
- A schema annotation meaning the field may carry the JSON
nullvalue. OpenAPI 3.0 used a separatenullable: truekeyword; 3.1 expresses the same idea withtype: [..., "null"]aligning with JSON Schema 2020-12. - exactOptionalPropertyTypes
- A TypeScript compiler flag that distinguishes optional fields (the key may be absent) from fields whose value may be
undefined. Enables types that mirror JSON's missing-vs-null distinction faithfully.
Frequently asked questions
Should an empty list be null or [] in JSON?
Default to [] for an empty list. It says "I looked, and there are zero items" — which is the answer most consumers expect. The shape matches a non-empty list, so client code can iterate without a null check, and array helpers (map, filter, length) work without guarding. Use null only when the empty value carries a different meaning than zero items — for example, "we have not computed this yet" or "the user has not chosen". A boolean preferences field that has never been set is genuinely null; the user has not answered. An empty tag list on a post that simply has no tags is []. Many APIs that mix the two convention create real bugs in clients that forget to coalesce null to []. Pick one rule per field, document it in the OpenAPI schema, and have your serializer enforce it — do not let null leak in by accident from a database row where the column was nullable but the API contract says it should not be.
What's the difference between null and missing in JSON?
null is an explicit value the server chose to send — the field is present in the JSON object and its value is the null literal. A missing field is one the server never wrote at all — the key does not appear in the object. To a strict reader they are different: a JSON Schema with required: ["foo"] accepts foo: null but rejects an object where foo is absent. In TypeScript, with exactOptionalPropertyTypes, foo: null and foo absent are different types. In JSON Merge Patch (RFC 7396), null and missing are the most different they ever get — null means delete this field on the target, missing means leave it alone. The rule of thumb: use null when the server is communicating "I considered this and the answer is no value", and omit the field when the answer is "not applicable" or when leaving it out makes the response smaller and the client can infer a default.
How does JSON Merge Patch use null for deletions?
RFC 7396 JSON Merge Patch defines a simple semantics for partial updates: PATCH a resource with a JSON object, and for each key in the patch, that key replaces the same key in the target. The special rule is that null in the patch deletes the field from the target. So PATCH { "nickname": null } removes the nickname field entirely, while PATCH { "nickname": "" } sets it to an empty string. This is why mixing null and missing in your representation matters: if the same field can legitimately be null in normal responses, you cannot also use null-means-delete in PATCH without breaking the round-trip. The standard workarounds are to switch to RFC 6902 JSON Patch (which has explicit add, remove, replace operations and never overloads null), or to declare in your API style that some fields never carry null in PATCH bodies — only their actual or removed states.
Should optional fields default to null or be absent?
Prefer absent for fields that have no value and where the consumer can infer a default. Absent fields shrink the payload, do not require the client to handle null specifically, and let the schema evolve more freely — adding a new optional field does not break existing clients because they never see it until populated. Prefer null when the absence itself carries information — the server has explicitly considered the field and chosen no value, and the client needs to distinguish "unset" from "not asked". JSON:API takes a strong position here: a missing relationship attribute means the server did not include it in this response (and the client should not assume anything), while an explicit null relationship means there is genuinely no related resource. Document the convention per field. Mixing the two without a written rule produces clients that guess wrong half the time.
How do I model nullable arrays in OpenAPI?
OpenAPI 3.1 (the version aligned with JSON Schema 2020-12) uses the JSON Schema type array form: type: ["array", "null"]. The older OpenAPI 3.0 dialect used a separate keyword, nullable: true, which is gone in 3.1 — if you copy 3.0 schemas forward without migrating, your nullable arrays silently become non-nullable. To express "the field is required to be present but may be null or an array", combine type: ["array", "null"] with required: [yourField]. To express "the field is optional and when present must be a non-null array", leave it out of required and set type: array. Avoid mixing nullable with empty-array as the same concept — pick one in your style guide. See our JSON Schema nullable guide for the full migration matrix between 3.0 and 3.1 dialects and how Ajv handles each.
What is the difference between undefined and null in JSON?
JSON has no undefined — the spec (RFC 8259) defines only null, true, false, numbers, strings, arrays, and objects. The TypeScript distinction between undefined and null only exists in your JavaScript runtime, never in the wire format. When JSON.stringify encounters an object property whose value is undefined, it drops the key from the output entirely; the field becomes missing. When it encounters null, it serializes the literal null. So in TypeScript, foo: undefined round-trips as "field absent" and foo: null round-trips as "field present with null value". This is why exactOptionalPropertyTypes matters: without it, foo?: string allows undefined to be assigned even when you meant absent. With it, the type system enforces the same distinction the wire format does — undefined means absent, null is a different beast.
How should databases store these distinctions?
A scalar column in SQL has two states — a value or SQL NULL — which maps cleanly to JSON value or null but loses the missing distinction. A JSONB column in Postgres can store the JSON null literal as one value and the SQL NULL of the column itself as another, so a single column can encode three states: column is SQL NULL (treat as missing in JSON output), column is JSON null (emit null in JSON output), column is a non-null JSON value. For arrays, a nullable array column maps SQL NULL to your choice of [] or null in JSON — set the convention in your serializer, not in the column type. The cleanest pattern is to make scalar columns NOT NULL with a default ("", 0, false, or a sentinel) and reserve nullability for fields where the difference between "no value" and "default value" genuinely matters to the application.
Is it OK to mix conventions in one API?
Mixing per-field is fine and often necessary — different fields carry different meanings, and forcing every empty list to be [] when some fields genuinely have a "not yet computed" state will lose information. Mixing within the same field across different endpoints is not fine: a tags field that is [] in GET /posts/1 and null in GET /posts/2 with no documented difference is a bug. The rule is: pick a convention per field, document it in the schema (OpenAPI, JSON Schema), enforce it in the serializer, and validate it in tests. Reviewers should reject PRs that introduce a new field without specifying its absence semantics. The cost of inconsistency is paid by every client integrating with you, multiplied by every language and framework they use. The cost of documentation is paid once, by the person writing the schema. Always cheaper to document.
Further reading and primary sources
- RFC 8259 — The JSON Data Interchange Format — The current JSON standard. Defines null as a value and is silent on the missing-field distinction (that lives in the schema layer).
- RFC 7396 — JSON Merge Patch — Partial-update protocol where null in the patch means delete the field. The reason null and missing must be kept distinct on the wire.
- RFC 6902 — JavaScript Object Notation (JSON) Patch — The verbose alternative to Merge Patch — explicit add/remove/replace operations that never overload null.
- OpenAPI 3.1.0 Specification — Schema Object — How OpenAPI 3.1 aligns with JSON Schema 2020-12 and replaces nullable: true with type: [..., "null"].
- JSON Schema 2020-12 — Type — The type keyword can be a single type or an array of types, including "null". The mechanism behind nullable arrays in OpenAPI 3.1.
- TypeScript — exactOptionalPropertyTypes — Compiler flag that enforces the distinction between optional (absent) and explicitly undefined — mirrors the JSON wire-format distinction.