The integration bugs that have cost me the most time were not parse errors. A parse error is loud: something throws, a request fails, you look. The expensive ones were payloads that parsed successfully and meant something different from what the code assumed - a field that was missing rather than null, a new field an upstream API added without notice, an amount that arrived as a string. Whether those become an exception or a quietly wrong value is decided by your JSON library's defaults, and most people have never looked at them.
So I looked. I took one small model, wrote it in the three stacks I work in - Java, Python, and TypeScript - and fed each library the same thirteen payloads, twelve of them wrong in some way. The results are below, and a few of them surprised me.
The setup
The model is an order with a required numeric ID, a required name, an optional note, and a required list of tags. Here is the most natural way to write it in each library:
// Java - Jackson 2.22.3 and Jackson 3.2.3, on Java 25
public record Order(long id, String name, String note, List<String> tags) {}
# Python - Pydantic 2.13.5, on Python 3.12
class Order(BaseModel):
id: int
name: str
note: str | None = None
tags: list[str]
// TypeScript - Zod 4.6.5, on Node.js 24
const Order = z.object({
id: z.number().int(),
name: z.string(),
note: z.string().nullish(),
tags: z.array(z.string()),
});
Each library runs with its defaults: a plain ObjectMapper for
Jackson 2, JsonMapper.builder().build() for Jackson 3,
model_validate for Pydantic, and safeParse for Zod. The
thirteen cases, and the scripts that run them, are in the
site's GitHub repository,
so every cell below can be reproduced.
The results
"OK" means the payload was accepted, followed by what the field ended up as. "Error" means the library rejected it. The valid baseline case was accepted by all four and is left out.
| Payload | Jackson 2 | Jackson 3 | Pydantic 2 | Zod 4 |
|---|---|---|---|---|
note missing | OK, null | OK, null | OK, None | OK, key absent |
"note": null | OK, null | OK, null | OK, None | OK, null |
name missing | OK, null | OK, null | Error | Error |
"name": null | OK, null | OK, null | Error | Error |
id missing | OK, 0 | OK, 0 | Error | Error |
"id": null | OK, 0 | Error | Error | Error |
Unknown field "coupon" | Error | OK, ignored | OK, ignored | OK, stripped |
"id": "42" | OK, 42 | OK, 42 | OK, 42 | Error |
"id": 42.7 | OK, 42 | OK, 42 | Error | Error |
"name": 123 | OK, "123" | OK, "123" | Error | Error |
tags missing | OK, null | OK, null | Error | Error |
"tags": null | OK, null | OK, null | Error | Error |
Of the twelve wrong payloads, Jackson 2 accepted eleven and Jackson 3 accepted eleven - a different eleven. Pydantic accepted four, and Zod three. The rows in bold are the ones that matter most: the payload was wrong, the library accepted it, and the object your code receives looks valid.
Five things the table says
1. A Java record has no idea which fields are required
Pydantic and Zod know that name is required because the type says
so - str is not str | None. A Java String
can always be null, so Jackson has no way to tell a required string
from an optional one, and by default it does not try. Missing name,
null name, missing tags - all of them produce a record
that constructs fine and throws a NullPointerException somewhere far
away, usually in code that has nothing to do with parsing.
2. A missing number becomes zero
long id is a primitive, and a primitive cannot be null, so when the
key is missing Jackson fills in the default: 0. Zero is a
plausible-looking ID. It will pass a != null check, go into a log line,
and in the worst case be used to look something up. Jackson 3 tightened part of
this - an explicit "id": null is now an error - but a missing
id is still silently 0 in both versions.
3. Jackson truncates fractions into integers
"id": 42.7 became 42, with no warning, in both Jackson
versions. That is the ACCEPT_FLOAT_AS_INT feature, which is on by
default. For an ID it is merely odd. For a quantity or an amount in minor units it
is a real bug: an upstream service that starts sending 1999.5 where you
expected whole cents gets rounded down, and nothing tells you. Pydantic refuses
(int_from_float) and so does Zod. Pydantic's line is precise: it
accepts 42.0 as 42, because nothing is lost, and rejects
only fractions that would be.
4. Jackson 3 flipped the unknown-field default
This is the result I did not expect. Jackson 2's plain ObjectMapper
has FAIL_ON_UNKNOWN_PROPERTIES enabled - the famous
UnrecognizedPropertyException - and Jackson 3's
JsonMapper has it disabled. I checked the flags
directly rather than trusting the behaviour alone:
Jackson 2.22.3 FAIL_ON_UNKNOWN_PROPERTIES=true FAIL_ON_NULL_FOR_PRIMITIVES=false
Jackson 3.2.3 FAIL_ON_UNKNOWN_PROPERTIES=false FAIL_ON_NULL_FOR_PRIMITIVES=true
If you run Spring Boot, you will not notice the first change: Boot has disabled
unknown-property failures for years, and Spring Boot 4 moved to Jackson 3. Where
you will notice it is anywhere that builds its own mapper - unit tests,
command-line tools, libraries. A test that used to fail when a payload had an extra
field will pass after the upgrade. And the second change runs the other way:
payloads with null in a primitive field that Jackson 2 accepted as
0 now throw. Both are worth a line in your upgrade notes. Jackson 3
also made its exceptions unchecked - JacksonException now extends
RuntimeException - so code that relied on the compiler to force a
catch no longer gets that reminder.
5. Only Zod keeps "missing" and "null" apart by default
Look at the first two rows. Jackson and Pydantic turn both "no
note key" and "note": null into the same value. Zod's
output keeps them different: the key is absent in one result and
null in the other. That distinction is the whole meaning of a PATCH
request - absent means "leave it alone", null means "clear it" - and a model that
erases it cannot implement PATCH correctly.
Zod only gets this right if you choose the modifier carefully, because the three options accept different things:
| Zod 4 | Key missing | null |
|---|---|---|
z.string().optional() | Accepted | Rejected |
z.string().nullable() | Rejected | Accepted |
z.string().nullish() | Accepted | Accepted |
This is the same split the JSON to TypeScript
converter makes when it writes field?: for a key missing from some
records and field: T | null for a key that is null on some. If your
TypeScript type says one and your Zod schema says the other, one of them is wrong.
In Pydantic the value is lost but the information is not.
model_fields_set records which fields were actually present in the
input:
Patch.model_validate({}).model_fields_set # set()
Patch.model_validate({"note": None}).model_fields_set # {'note'}
# and model_dump(exclude_unset=True) gives you exactly the PATCH body:
# {} versus {'note': None}
Making each library strict
Every one of these libraries can be made to reject all twelve bad payloads while still accepting the two legitimate forms of an optional note. The configuration is very different in size.
Zod: the default schema already rejects every type mismatch.
To reject unknown keys too, use z.strictObject instead of
z.object - it fails with unrecognized_keys.
Pydantic: one line of configuration covers both coercion and extra fields.
class StrictOrder(Order):
model_config = ConfigDict(strict=True, extra='forbid')
With that, "42" is rejected as int_type and the
unknown field as extra_forbidden.
Jackson needs the most, because each silent acceptance is a separate feature. This record and mapper reject exactly what strict Pydantic rejects - I ran it against all thirteen cases in both Jackson 2 and Jackson 3:
public record Order(
@JsonProperty(required = true) long id,
@JsonProperty(required = true) @JsonSetter(nulls = Nulls.FAIL) String name,
String note,
@JsonProperty(required = true) @JsonSetter(nulls = Nulls.FAIL) List<String> tags) {}
// Jackson 3 (Jackson 2: use JsonMapper.builder() too, then
// mapper.coercionConfigFor(...).setCoercion(...) after build())
JsonMapper mapper = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) // already on in 3
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.withCoercionConfig(LogicalType.Integer,
cfg -> cfg.setCoercion(CoercionInputShape.String, CoercionAction.Fail))
.withCoercionConfig(LogicalType.Textual,
cfg -> cfg.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail))
.build();
Two details are easy to get wrong. @JsonProperty(required = true)
checks only that the key is present, so "name": null still
gets through without @JsonSetter(nulls = Nulls.FAIL). And the global
switch FAIL_ON_MISSING_CREATOR_PROPERTIES, which looks like the
shortcut, is too blunt for records: in my run it rejected a missing
note as well, because it treats every record component as required.
So which behaviour do you want?
The defaults bundle two separate decisions together, and it helps to pull them apart.
- Unknown fields are about who controls the contract. When you
consume a third-party API, ignore them: providers add fields without
notice, and treating that as an outage is the wrong trade. When you
receive requests into your own service, reject them: an unknown field
there is usually a client typo -
"emial"- that would otherwise be silently dropped. - Types, nulls, and required fields should be strict in both directions. A missing ID, a fractional quantity, or a null where your code assumes a value is never something you want to discover three calls later.
By that standard, Zod's default is right for consuming APIs and
z.strictObject for receiving them. Pydantic's default is close, apart
from coercing "42". And Jackson's default - in either version - is
wrong on the second point, which is the one that produces bad data rather than
errors.
Checking a payload before you write the model
Most of these problems are visible in the data before any code runs, if you look
at more than one record. Paste a few real responses into
JSON Keyper with the Key paths with value types
format and Collapse array indices on, and a field that is a
number in one record and a string in another, or
null in some, shows up as two lines for the same path. The
TypeScript format goes further and marks
which fields are optional and which are nullable. Either is a faster way to find out
what the API actually sends than reading its documentation.
Reproduce it
The thirteen cases are one JSON file, cases.json, and each library
has a short script that reads it. The Java checks are built with Maven, the Python
one needs only Pydantic, and the Zod one is a single npm install. If
you are on different versions, run them before relying on any row above - these
defaults are exactly the kind of thing that changes between major releases, as
Jackson 3 just showed.
Further reading
- Jackson vs Gson vs Moshi - choosing a JSON library on the JVM
- JSON Schema: a practical guide - validating the payload itself, independent of any one language's model
- What is JSON? - the values that change when JSON crosses between languages
- JSON to TypeScript - generate a type that marks optional and nullable fields from real samples