Short answer: JSON can express "nothing" three ways, and they are not interchangeable.
nullmeans the field exists and is deliberately empty. A missing key means the field was not provided, which in a PATCH means "leave it alone". An empty string""is a real value of length zero. There is noundefinedin JSON; JavaScript drops those keys when serializing. Pick one meaning per field, write it in the schema, and send[]rather thannullfor empty lists.
A mobile client sends {"nickname": null} to clear a nickname. The server's update code does if (body.nickname) user.nickname = body.nickname, sees a falsy value, and skips it. The nickname never clears. Two weeks later another client sends {"nickname": ""} and that one clears it, but now the profile page shows an empty badge where it used to show nothing. All three representations are "nothing", and the code treated them as the same thing.
Use JSON Compare to inspect added, removed, and changed values in two non-sensitive sample documents.
The Three Ways to Say Nothing
{"nickname": null} // 1. explicit null: the value is known to be empty
{} // 2. missing key: nothing was said about nickname
{"nickname": ""} // 3. empty string: a string value with zero characters
From a parser's point of view these are three different documents. From a consumer's point of view the difference is intent. The first was a decision, the second was silence, and the third is data. A fourth option, undefined, exists only in JavaScript memory; the moment you serialize, it collapses into option two.
JSON.stringify({ a: null, b: undefined, c: "" });
// {"a":null,"c":""} <- b vanished
JSON.stringify([null, undefined, ""]);
// [null,null,""] <- in arrays, undefined becomes null
That asymmetry (dropped in objects, nulled in arrays) is the source of a lot of confusion, and it is covered with the rest of the serializer rules in the JSON.parse and JSON.stringify guide.
How Each Language Maps Them
| Language | null | Missing key | Empty string |
|---|---|---|---|
| JavaScript | null | undefined (key dropped by stringify) | "" |
| TypeScript | string | null | field?: string | string |
| Python | None | KeyError / dict.get() -> None | "" |
| Go | nil pointer (*string) | omitempty / omitzero | "" (zero value) |
| Java (Jackson) | null | NON_NULL / NON_ABSENT include rule | "" |
| SQL | NULL | No column / DEFAULT | '' (not NULL) |
Python: None goes both ways
json.dumps({"a": None}) gives {"a": null}, and a missing key is simply not in the dict. The trap is on the read side: data.get("a") returns None for both a null value and an absent key, so if you need to tell them apart, use "a" in data first. The Python json module guide has more on the type mapping.
Go: omitempty is not what you think
Go's omitempty tag drops the field when it holds the type's zero value. For a string that is "", for an int it is 0, and for a bool it is false, so a user who turns a setting off produces a payload with no setting at all. To represent all three states you need a pointer:
type Profile struct {
Nickname *string `json:"nickname,omitempty"`
}
// nil pointer -> key omitted
// pointer to "" -> "nickname": ""
// no way to emit null with omitempty; drop the tag to get "nickname": null for nil
Newer Go releases add omitzero, which treats a nil pointer and a zero struct the same way and works better with time values; check the encoding/json docs for your version.
Java and TypeScript: making the states explicit
Jackson's @JsonInclude(JsonInclude.Include.NON_NULL) turns every Java null into a missing key, which is convenient until you need to send a deliberate null. For three-state fields, the usual approaches are Optional<T> with the jdk8 module and Include.NON_ABSENT, or a wrapper such as JsonNullable from the OpenAPI generator. TypeScript can express it in the type itself:
interface ProfilePatch {
nickname?: string | null;
// undefined -> not sent (leave alone)
// null -> sent as null (clear)
// string -> sent as value (set)
}
With the exactOptionalPropertyTypes compiler flag, nickname?: string no longer accepts an explicit undefined, which keeps "not provided" and "provided as nothing" from blurring together in your own code.
PATCH Semantics: null Clears, Missing Leaves Alone
This is the place where the distinction stops being philosophical. JSON Merge Patch (RFC 7386) defines it precisely: a key set to null in the patch removes that member from the target; a key that is not in the patch is untouched.
// Current resource
{"name": "Priya", "nickname": "pri", "phone": "+91 98..."}
// PATCH body (Merge Patch)
{"nickname": null, "phone": "+91 99..."}
// Result
{"name": "Priya", "phone": "+91 99..."}
An update handler that uses a truthiness check breaks this contract on the first null. The correct test is key presence:
// Express
for (const field of ["nickname", "phone"]) {
if (Object.hasOwn(req.body, field)) {
user[field] = req.body[field]; // null is a valid instruction here
}
}
If you need to set a field to null and address array elements, Merge Patch cannot express the second part; that is what JSON Patch is for. The JSON Patch vs Merge Patch post compares the two.
Declaring It in JSON Schema
A schema is where the decision gets written down. "May be null" and "may be missing" are two separate keywords:
{
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"nickname": { "type": ["string", "null"] },
"phone": { "type": "string" }
},
"required": ["name", "nickname"]
}
// name: must be present, must be a non-empty string
// nickname: must be present, may be null
// phone: may be missing; if present, must be a string (null rejected)
Note minLength: 1 on name. Without it, "" passes as a valid string, which is almost never what a required name means. OpenAPI 3.0 used a separate nullable: true keyword; OpenAPI 3.1 adopted the JSON Schema type array. If you are writing your first schema, JSON Schema explained covers required and the type keywords from the start.
Where the Database Fits In
SQL has its own version of this split, and it does not line up with JSON by accident of defaults. A NULL column maps naturally to JSON null, and an empty VARCHAR maps to "", but "missing key" has no column equivalent at all: a row always has every column. So an API that omits unset fields is doing that translation in application code, and the rule needs to be written down somewhere a new developer will find it.
-- These are different rows
SELECT * FROM users WHERE nickname IS NULL; -- never set, or cleared
SELECT * FROM users WHERE nickname = ''; -- set to empty on purpose (or by a sloppy form)
-- One normalization, applied on write, removes the ambiguity
UPDATE users SET nickname = NULLIF(TRIM(nickname), '');
Document stores are looser. MongoDB distinguishes a field set to null from a field that does not exist, and { nickname: null } as a query matches both unless you add $exists. PostgreSQL's jsonb likewise keeps null values as members, so data ? 'nickname' is true for a null member and false for a missing one. Whatever the store, the API contract should not change based on which one is underneath.
Recommendation Table
| Situation | Send | Why |
|---|---|---|
| Value is known to be absent (no middle name) | null | Explicit; client can distinguish from "not fetched" |
| Field not requested or not applicable to this type | Omit the key | Keeps payloads small; schema marks it optional |
| User typed nothing in a text field | "" or null, pick one and document it | Mixing both doubles every client check |
| PATCH: clear a value | null | Merge Patch semantics: null means delete |
| PATCH: leave a value alone | Omit the key | Missing means untouched |
| Collection with no items | [] (never null) | Clients can iterate without a guard |
| Nested object not loaded | Omit or null, consistently | Document which; never an empty {} that looks loaded |
The row that generates the most argument is the empty text field. Either answer works; the failure mode is having both null and "" in the same column of the same table. Normalize at the boundary (for example, trim and convert empty input to null before it reaches the database) and the rest of the system only ever sees one form.
Common Mistakes
- Truthiness checks on incoming fields.
if (body.count)treats0,"",false, andnullidentically. Test for key presence or!== undefinedinstead. - Returning null for empty arrays.
{"items": null}forces every client to guard before.map(). Send[]. The array vs object guide covers the related choice of container shape. - Depending on undefined surviving the wire. It never does. A TypeScript type that says
undefineddescribes memory, not the payload. - Mixing conventions across endpoints. One endpoint omits unset fields, another sends them as null, and the shared client code has to know which is which. Decide once for the whole REST API.
- Database defaults disguising the problem. A column with
DEFAULT ''turns every omitted field into an empty string on insert, so the API appears to "convert" missing to empty. Check the schema before blaming the serializer.
When you are auditing an existing payload for mixed conventions, paste a few responses into JSON Prettifier with Sort Keys on; aligned keys make a null next to a missing key next to an "" easy to spot.
Query filters introduce another boundary: a present false-like value can behave differently from an absent property across evaluators. These JSONPath migration checks include explicit fixtures for null, false, zero, empty strings, and missing fields.