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.

PayloadJackson 2Jackson 3Pydantic 2Zod 4
note missingOK, nullOK, nullOK, NoneOK, key absent
"note": nullOK, nullOK, nullOK, NoneOK, null
name missingOK, nullOK, nullErrorError
"name": nullOK, nullOK, nullErrorError
id missingOK, 0OK, 0ErrorError
"id": nullOK, 0ErrorErrorError
Unknown field "coupon"ErrorOK, ignoredOK, ignoredOK, stripped
"id": "42"OK, 42OK, 42OK, 42Error
"id": 42.7OK, 42OK, 42ErrorError
"name": 123OK, "123"OK, "123"ErrorError
tags missingOK, nullOK, nullErrorError
"tags": nullOK, nullOK, nullErrorError

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 4Key missingnull
z.string().optional()AcceptedRejected
z.string().nullable()RejectedAccepted
z.string().nullish()AcceptedAccepted

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