Repository navigation
[BUG] [JAVA-SPRING] 7.24.0: generated field-level @JsonInclude(NON_NULL) overrides project-wide ObjectMapper inclusion #24401
Description
Activity
Thanks for the report, you're right and this is a valid bug.
The field-level @JsonInclude(JsonInclude.Include.NON_NULL) added in 7.24.0 (via #23993) was meant to stop generated models from emitting explicit null for optional non-nullable fields. But hard-coding it at the field level is harmful for anyone running a stricter global policy like non_empty or non_default: it overrides the global ObjectMapper and starts emitting empty strings/collections/maps that were previously omitted.
This is already being discussed on #23976 (here and here).
The plan: make the annotation configurable, something like NON_NULL (default) / NON_EMPTY / NON_DEFAULT / none, where none generates nothing and defers fully to your global mapper. The contract only needs us to not emit explicit null for optional non-nullable fields, and NON_EMPTY/NON_DEFAULT already satisfy that. Obviously setting the policy to none reopens the original problem (optional non-nullable fields can serialize explicit null), but that becomes the user's explicit choice, so I think it's the right call.
In the meantime you can unblock yourself with a custom template. Copy pojo.mustache into your templateDirectory and remove the block emitting the annotation:
{{^isNullable}}
@JsonInclude(JsonInclude.Include.NON_NULL){{/isNullable}}I'll tag you on the PR. Apologies for the inconvenience.
@Picazsoo No worries and thank you very much for the detailed response!
Hi @gs-covariance and @MarcRoser and @MelleD, thanks again for raising this. You were right, and here's the plan to fix it.
The problem
The hard-coded @JsonInclude(JsonInclude.Include.NON_NULL) (@field:JsonInclude in Kotlin) on properties overrides any stricter global mapper policy (NON_EMPTY / NON_DEFAULT) with no way to opt out. That's a clear regression.
Proposed changes
Two new config options:
-
optionalNonNullPropertyJsonInclude(enum, defaultNON_NULL)- Values:
NON_NULL/NON_EMPTY/NON_DEFAULT/NONE - Would set the
@JsonIncludepolicy for optional, non-nullable properties, orNONEto not emit the annotation at all (so your global mapper config would apply). - Default
NON_NULLwould keep current behavior on upgrade.
Under the hood, the resolved value would be written into a single universal vendor extension on the property, and the template would simply emit whatever that extension holds. The codegen would apply the global default only if that extension has not already been set manually on the property. So you get a clear precedence:
- a manual per-property value (vendor extension set directly in the spec) always wins, otherwise
- the global default from
optionalNonNullPropertyJsonIncludeis used.
- Values:
-
generateJsonIncludeAnnotations(boolean, defaulttrue)- When
false, all policy@JsonIncludeannotations (theALWAYS/NON_NULLon required properties and the configured policy on optional non-nullable properties) would be omitted, so your globalObjectMapperconfiguration would win for those properties. This is the opt-out you asked for @MelleD. - The
@JsonInclude(NON_ABSENT)onJsonNullableproperties would not be affected by this flag and would still be emitted (see below).
- When
Important scoping
A manual per-property override (the vendor extension set directly in the spec) would always win, for any property, required or optional, and regardless of the generateJsonIncludeAnnotations flag. If you set it explicitly, that is what gets emitted, full stop.
Everything below describes only the automatic behavior that kicks in when no manual override is present on the property.
By default (generateJsonIncludeAnnotations=true), the automatic per-property configurability / opt-out via optionalNonNullPropertyJsonInclude would apply only to optional properties. For required properties we would automatically emit an explicit @JsonInclude, because omitting it (or applying a stricter policy) would break the OpenAPI contract:
- A required field must always be present in the serialized JSON. Under a global
NON_EMPTY/NON_DEFAULTpolicy, a required empty collection/string/0/falsewould be silently dropped, violatingrequired. - So required fields would get an explicit annotation that guarantees presence:
- Kotlin required non-nullable would use
ALWAYS(the Kotlin type already preventsnull). - Java required non-nullable would use
NON_NULL(Java can't type-preventnull; see the note at the end for why omission is the better failure mode than an explicitnull), and required nullable would useALWAYS(explicitnullis valid and must be serialized).
- Kotlin required non-nullable would use
If you set generateJsonIncludeAnnotations=false, this automatic contract protection is intentionally turned off as well: the automatic required-field annotations would also be dropped, and required fields would then fall back to your global mapper policy (unless you've set a manual per-property override). That is the full escape hatch for people who manage inclusion entirely via their global ObjectMapper, but be aware it re-introduces the footgun that a stricter global policy could silently omit a required empty value. It's opt-in and off by default for exactly that reason.
The one genuinely non-negotiable case
For openApiNullable=true, @JsonInclude(NON_ABSENT) on JsonNullable<T> fields would always be emitted, regardless of either option (including generateJsonIncludeAnnotations=false). It's not a policy choice, it's what makes JsonNullable distinguish absent from explicit null, so disabling it would break openApiNullable semantics entirely.
Summary matrix
Automatic defaults (when no manual per-property override is set and generateJsonIncludeAnnotations=true):
| Property | Nullable | Kotlin (auto) | Java (auto) |
|---|---|---|---|
| required | non-nullable | ALWAYS |
NON_NULL (see below) |
| required | nullable | ALWAYS |
ALWAYS |
| optional | non-nullable | NON_NULL (via optionalNonNullPropertyJsonInclude) |
NON_NULL (via optionalNonNullPropertyJsonInclude) |
| optional | nullable (openApiNullable=true) |
NON_ABSENT |
NON_ABSENT |
How the automatic defaults can be changed:
| Mechanism | Scope | Effect |
|---|---|---|
| Manual per-property vendor extension | any single property (required or optional) | Always wins. Emits exactly the value you set, regardless of everything below. |
optionalNonNullPropertyJsonInclude (NON_NULL/NON_EMPTY/NON_DEFAULT/NONE) |
all optional non-nullable properties | Sets their automatic policy; NONE emits nothing. |
generateJsonIncludeAnnotations=false |
all required + optional non-nullable properties | Drops all automatic policy annotations (the required ALWAYS/NON_NULL and the optional policy). Does not affect NON_ABSENT on JsonNullable, and does not affect manual overrides. |
NON_ABSENT on JsonNullable<T> (only present when openApiNullable=true) is always emitted and is not affected by any of the options above.
This makes the generated output respect global mapper config where it can, without weakening the contract where it can't. And you can always embrace the danger zone if you wish to.
Does this make sense? I think this should satisfy everyone and it should be very flexible. The need to introduce two config options is a bit troublesome but I cannot see a way to logically combine these into one without causing even more confusion.
In the meantime you can unblock yourself with a custom dataClassOptVar.mustache (Kotlin) / pojo.mustache (Java) that drops the @JsonInclude line so your global policy would apply again.
Thanks for all the sharp feedback and sorry once again for any inconvenience caused.
Why NON_NULL and not ALWAYS for Java required non-nullable
For Java required non-nullable, NON_NULL omits the field, which fails cleanly as a "missing required field" at the consumer boundary. ALWAYS would instead put "field": null on the wire, a positive, type-invalid assertion that a lenient consumer might accept and propagate. A missing required field is the safer, louder, more standard failure than an explicit null on a non-nullable field.
I wasn't right; rather, I had a hunch—or rather, experience—with annotations that they should be used with caution.
The @JsonInclude(NON_ABSENT) on JsonNullable properties would not be affected by this flag and would still be emitted (see below).
I checked my own code, and none of my DTOs—including those with JsonNullable—require any JsonInclude. I don’t have a single JsonInclude in the Spring Generator (7.23.0), not even for JsonNullable. It’s all handled by the ObjectMapper.
@gs-covariance can you share your openapi schema for the generated DTO
@MelleD As I said before, this happens on every optional non-nullable field
openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths: {}
components:
schemas:
Example:
type: object
properties:
a:
type: string
b:
type: integer
c:
type: array
items: { type: string }@gs-covariance just want do double check if you have right format and etc. but then I will have the same issue when I upgrade
Hi @MelleD, I did some tests locally and I confirm that @JsonInclude(NON_ABSENT) is not necessary at all when the JsonNullableModule or JsonNullableJackson3Module are properly registered in the jsonMapper/objectMapper.
So I definitely concede that point - for JsonNullable<T> those @JsonInclude(NON_ABSENT) annotations are useless and just noise. The JsonNullable module's serializer takes precedence no matter what global inclusion is specified.
I'll remove the NON_ABSENT annotations from the templates.
I do want to keep the scope explicit, since these are two separable claims:
NON_ABSENTonJsonNullable<T>(theopenApiNullable=truepath) → redundant, dropping it. ✅ Conceded.NON_NULLon optional non-nullable fields (the defaultopenApiNullable=falsepath) → still needed. There's no wrapper type there — it's a plainT? = null- so no datatype module participates and inclusion falls back entirely to the global policy. UnderALWAYS(Jackson's baseline / Spring's effective default) that serializes"field": nullfor a field the schema declares non-nullable, which is spec-invalid output. So unlikeNON_ABSENT, that one isn't noise.
Net plan: drop NON_ABSENT on JsonNullable fields, keep NON_NULL on optional non-nullable fields, and still expose the opt-out configOption for anyone who wants to own inclusion entirely at the mapper level. I'll wire this into the follow-up PR and tag you on it.
Revised plan (post-NON_ABSENT concession)
Following up on my last comment to fold the NON_ABSENT finding back into the overall design, so the earlier proposal and matrix don't mislead anyone reading this thread later.
What changed from the original proposal:
@JsonInclude(NON_ABSENT)onJsonNullable<T>fields is being dropped entirely. Verified locally: whenJsonNullableModule/JsonNullableJackson3Moduleis registered, the module's serializer takes precedence over any global inclusion policy, so the annotation is pure noise. This removes the "one genuinely non-negotiable case" from my earlier comment. It turns out there wasn't one.- Because of that,
generateJsonIncludeAnnotations=falsenow becomes a true, complete escape hatch: with it off, generated models carry no policy@JsonIncludeat all, and inclusion is owned 100% by the globalObjectMapper. NoJsonNullablecarve-out anymore.
What stays the same:
NON_NULLon optional non-nullable fields is still emitted by default. There's no wrapper type on that path (T? = null/ plainT), so no datatype module participates and inclusion falls back to the global policy. Under Jackson'sALWAYSbaseline that serializes"field": nullfor a field the schema declares non-nullable, which is spec-invalid, so this one is not noise.- Both config options remain:
optionalNonNullPropertyJsonInclude(NON_NULLdefault /NON_EMPTY/NON_DEFAULT/NONE): policy for optional non-nullable fields;NONEemits nothing.generateJsonIncludeAnnotations(defaulttrue): global on/off for all policy annotations.
- Manual per-property vendor extension still always wins.
Updated summary matrix (no manual override, generateJsonIncludeAnnotations=true):
| Property | Nullable | Kotlin (auto) | Java (auto) |
|---|---|---|---|
| required | non-nullable | ALWAYS |
NON_NULL |
| required | nullable | ALWAYS |
ALWAYS |
| optional | non-nullable | via optionalNonNullPropertyJsonInclude (default NON_NULL) |
via optionalNonNullPropertyJsonInclude (default NON_NULL) |
| optional | nullable (openApiNullable=true) |
(none, was NON_ABSENT, now dropped) |
(none, was NON_ABSENT, now dropped) |
@gs-covariance, thanks for the minimal repro, that matches exactly what the NON_NULL-on-optional path produces, and it's the behavior these options are meant to let you control. @MelleD, with the NON_ABSENT removal, generateJsonIncludeAnnotations=false gives you the clean "let the ObjectMapper own everything" mode you were describing.
What do you think?
Thanks for your patience.
Context: impact at scale in a corporate framework
We maintain a corporate Java/Spring framework that wraps openapi-generator (custom generators extending SpringCodegen/JavaClientCodegen) and is used by hundreds of development teams. Every generator version bump propagates automatically to all services that regenerate their code at build time.
While evaluating the upgrade from 7.23.0 to 7.24.0, we compared the generated code of our integration modules between both versions: ~470 server DTOs gained ~1,550 new annotations (@JsonInclude(NON_NULL) on fields and @JsonSetter(nulls = Nulls.SKIP) on setters) introduced by #23993 and #24185. We have decided to stay on 7.23.0 until there is an upgrade path that does not change the serialization behavior of our services.
The problem, from the perspective of someone operating many services
The change is doubly silent:
- Responses (
@JsonInclude(NON_NULL)): as already described in this issue, the field-level annotation takes precedence over the globalObjectMapperpolicy, and consumers that distinguish "field present with null" from "field absent" see a de facto contract change. - Requests (
@JsonSetter(nulls = Nulls.SKIP)): this is, for us, the most severe one and the least discussed in this thread. An explicitnullin the request body is now silently ignored and the default value is kept. The semantics of PATCH-style operations change:{"field": null}used to set null; now it doesn't. It doesn't fail at compile time, at startup, or at runtime — it simply changes data. In a mass migration, this class of change is nearly impossible to detect until it produces a data incident.
Note that #23993 explicitly asked for java-spring feedback before merging ("It would be really good to get some java-spring feedback for the Nulls.SKIP for optional non-nullable"). This comment intends to be that feedback, with the use case of operating the change at scale.
Main request: make the 7.25.0 default preserve the 7.23.0 output
We propose that generateJsonIncludeAnnotations default to false, so that the default behavior on upgrade is that of 7.23.0, and the protection against emitting explicit null becomes opt-in.
We understand the correctness argument (under ALWAYS, an optional non-nullable field can serialize "field": null, which is spec-invalid). But that behavior has been the status quo for all generator users for years: fixing it by default turns a tooling upgrade into an unsolicited wire contract change. The spec-correctness fix is valuable — as a conscious user choice, not as a side effect of a version bump. The precedent in this very discussion points in that direction: NON_ABSENT was conceded as unnecessary after feedback; the principle "the default doesn't change the wire" is the generalization of that same logic.
Fallback requests (if the default stays true)
- Make
generateJsonIncludeAnnotations=falsealso cover the@JsonSetter(nulls = Nulls.SKIP)introduced by [kotlin-spring, java-spring] - gate @JsonSetter on openApiNullable for optional non-nullable fields #23993. The plan described in this thread only mentions the@JsonIncludeannotations; if the flag does not also disable the@JsonSetter, the escape hatch is incomplete: the deserialization semantics change (for us the most dangerous one) would remain without an opt-out. Alternatively, a dedicated flag for it. - Highlight the behavior change prominently in the release notes of 7.24.x/7.25.0 as an explicit behavior change (response serialization + null-handling semantics in deserialization), with the restoring flag documented. The current 7.24.0 highlights do not make it possible to anticipate the impact without diffing generated code.
Thank you for the transparency with which this issue is being handled — the proposed flag plan is already a big improvement; these requests aim to make the upgrade path safe also for those walking it with hundreds of services at once.
Hi @jorgerod, first let me apologize for any inconvenience caused, and thanks for the feedback I specifically asked for.
On the default for generateJsonIncludeAnnotations: that's fair and up for discussion, I don't mind either option. Whatever the default, it needs a loud, clear mention in the release notes, and possibly a clear log line during generation so nobody gets surprised on upgrade.
On wholesale dropping of the @JsonSetter annotations: I'd honestly prefer a separate boolean configOption for that, rather than overloading generateJsonIncludeAnnotations, so the serialization opt-out and the deserialization opt-out stay independent.
Now to the Nulls.SKIP part specifically, because I think the baseline it's compared against is worth examining.
Scoping note first: AFAIK @JsonSetter(nulls = Nulls.SKIP) is only ever emitted on optional non-nullable fields (!required && !isNullable). Never on required fields, never on nullable ones. So the only affected fields are ones the schema says cannot hold null, which means an incoming {"field": null} there is already out-of-contract input.
That reframes the comparison. The real question isn't "should an explicit null set null?" (for a non-nullable field it arguably never should), but what to do with an illegal null:
- Before (no annotation, Jackson's default
Nulls.SET): thenullis assigned. The non-nullable field ends up holdingnull, a spec-invalid state accepted silently. To me that's the worst option: it doesn't reject the null, doesn't ignore it, it commits it. - Now (
Nulls.SKIP): the illegal null is dropped and the field keeps its default. Still silent, but the field never enters a state the schema forbids.
So I fully agree the silence of the change (and the lack of a prominent release-note signal for a fleet-wide upgrade) is a real problem worth fixing. But I don't think the old behavior was a "correct" baseline we regressed from. It was itself silently violating the non-nullable contract.
On the PATCH example specifically ({"field": null} used to set null, now it doesn't): I want to be precise here, because two conditions are mutually exclusive. {"field": null} meaning "set this to null" is only a valid expectation for a nullable field. A nullable field with openApiNullable=true is generated as JsonNullable<T> and gets no @JsonSetter(nulls = SKIP) at all, so null-setting keeps working there. Nulls.SKIP is only ever added to non-nullable fields, where an explicit null was never a legal value to begin with. So there is no field on which "PATCH null-setting was valid" and "Nulls.SKIP now silences it" are both true. Also worth stressing: @JsonSetter only affects deserialization of request bodies. It has no effect on response serialization, which is the separate @JsonInclude concern.
If it were up to me, for these optional non-nullable fields I'd lean toward Nulls.FAIL: loudly reject the illegal null and enforce the contract on both sides, which is exactly the "louder failure beats silent data change" principle you invoke elsewhere. For genuinely nullable fields where explicit null is meaningful, openApiNullable=true with JsonNullable<T> is the right tool, since it preserves the absent-vs-null distinction.
Looking forward to your reply!
Hi @Picazsoo, thanks for the detailed response — and point conceded on the PATCH example.
1. On the scoping of Nulls.SKIP: you're right
Your scoping analysis is correct and I withdraw the example as I framed it: on a field declared nullable, null-setting keeps working (via JsonNullable<T>), and Nulls.SKIP only applies to fields where an explicit null was never legal input to begin with. The previous baseline (Nulls.SET assigning null to a non-nullable field) wasn't a "correct" behavior we regressed from either — it was a silent contract violation in the other direction.
2. The operational nuance I stand by
That said, the risk that concerns us at fleet scale doesn't disappear, it just changes nature: imperfect specs. In a large service base there is a real population of fields that are de facto nullable but were never declared nullable: true, with clients sending null and server code that has been handling it for years. That traffic is out-of-contract — no argument there — but it is operational today. With Nulls.SKIP, those flows change behavior without an error at any layer: the proper fix is fixing the spec, but the window between "I bump the generator" and "I discover which specs were wrong" is exactly where data incidents happen. Hence our insistence on opt-outs and conservative defaults: we're not defending the old behavior as correct, we're defending that moving from one to the other should be a conscious, per-service decision.
By the same logic: if Nulls.FAIL eventually lands for these fields (and we agree it's the most contract-honest behavior), we'd ask for it to be opt-in — it would turn that same tolerated traffic into deserialization errors across an entire fleet at once.
3. Separate flag for @JsonSetter: agreed
A dedicated boolean for the deserialization opt-out works for us — keeping the serialization axis (@JsonInclude) and the deserialization axis (@JsonSetter) independent is cleaner. With both flags available, our upgrade path is clean: we pin both in our corporate generator and each team decides when to adopt the new behaviors.
4. On the default for generateJsonIncludeAnnotations
Since you're leaving it open: our vote is default false (= 7.23.0 output on upgrade), with the protection as opt-in. The reasoning is the same as before: for an individual user the flag is a detail; for a fleet upgrade, the default is the behavior. And with NON_ABSENT already dropped as redundant, the cost of defaulting to false is only not protecting by default against the spec-invalid "field": null — which is the status quo everyone has today.
In any case, the prominent release-notes mention and the log line during generation sound like excellent ideas regardless of which default is chosen — the log line in particular helps exactly where release notes don't reach (transitive upgrades via frameworks/BOMs like ours).
Thanks again for how you're handling this.
Hi @jorgerod, your point of view makes a lot of sense. On the other hand it is strange to default to generating incorrect and misleading models that serialize and deserialize incorrectly. Imagine new adopters of the tooling: they generate their models, never think of it again, accidentally rely on the incorrect modelling, and later end up stuck in exactly the situation you're in now, except they never got a signal that a choice was being made for them.
Building on this: maybe the cleanest resolution is to default to the weaker/upgrade-safe contract, but log loudly that it is weak and can (and generally should) be tightened. The only way to mute the log is to set the flag explicitly, either to the stricter policy, or explicitly to the weak one to affirm "yes, I want this."
That gives us:
- flag unset → weak behavior + warning (nobody's build breaks, no wire change on upgrade, but the weakness is never silent),
- explicitly weak → weak behavior, no warning (muted by conscious choice),
- explicitly strict → correct behavior, no warning.
So the default preserves 7.23.0 output for you and the fleet, while new adopters can't unknowingly marry the weaker contract forever. They get a persistent, actionable nudge until they make a real decision. Silence becomes something you opt into, never something you inherit.
And this works per axis: safe-but-noisy default on both @JsonInclude and the @JsonSetter/Nulls flag, which keeps the dangerous deserialization axis conservative-by-default too. It's essentially your generation-time log-line idea, just escalated to fire specifically when the flag is left unset.
Does this make sense to you?
My assumption was that all relevant Jackson modules are properly registered everywhere, for example JsonNullableModule, the JDK modules, etc. The ObjectMapper should of course also be configured consistently across the whole application landscape; otherwise things become very hard to reason about.
So, in summary, for the Spring generator I do not think I need any @JsonInclude annotations at all. That said, I also do not have all theoretical cases in practice — out of the four possible combinations, only three are actually relevant in my setup.
My assumption was that all relevant Jackson modules are properly registered everywhere, for example JsonNullableModule, the JDK modules, etc. The ObjectMapper should of course also be configured consistently across the whole application landscape; otherwise things become very hard to reason about.
👍
So I gather you are on openApiNullable=true with a global mapper set to something stricter than ALWAYS (that's the only way you get zero annotations and still-correct output).
But that only works because your schema doesn't hit both conflicting cases at once. A single global inclusion policy can't satisfy both:
- optional + non-nullable wants the field omitted (no
null). Correct under globalNON_NULL/NON_EMPTY, but emits the spec-invalid"field": nullunderALWAYS. - required + nullable wants
"field": nullon the wire. Correct underALWAYS, but silently dropped underNON_NULL/NON_EMPTY, breakingrequired.
So tightening the global default fixes the first and breaks the second. There's no single global value that's right for both. That's precisely why the per-field @JsonInclude exists: each field carries the policy it needs (NON_NULL on optional non-nullable, ALWAYS on required nullable) independent of the one global setting.
Your "3 of 4 combinations" is the reason you don't feel it: as soon as a schema has both an optional non-nullable field and a required nullable one, the global-only approach breaks one of them. That's why the annotation stays generatable, with generateJsonIncludeAnnotations=false for setups like yours that stay within the safe subset.
So I gather you are on openApiNullable=true with a global mapper set to something stricter than ALWAYS (that's the only way you get zero annotations and still-correct output).
Correct non empty.
I do not have both cases:
Currently is:
optional empty is absent/blank/null
required is not null + min size 1
Hi, PR with the agreed-upon changes is here: #24428
Hello @MelleD , @jorgerod , @gs-covariance, the new version 7.25.0 has been released with the fix in place. Does it solve the issues for you? It is pretty configurable, so it should be able to satisfy all cases. But it would be great to have some concrete confirmation from you.
Description
Since 7.24.0 (via #23993),
JavaSpring/pojo.mustacheemits@JsonInclude(JsonInclude.Include.NON_NULL)on every optional non-nullable field.Field-level
@JsonIncludetakes precedence over the projectObjectMapperinclusion (e.g. Spring Boot'sspring.jackson.default-property-inclusion=non_empty). On upgrade, projects with a stricter global setting silently observe a wire change: fields that were previously omitted (empty collections, empty strings) begin to serialize because per-fieldNON_NULLis looser than the project default.What's the recommended path for consumers who need the project
ObjectMapperto remain the source of truth for inclusion policy? Field-level@JsonIncludeon generated models overrides any global setting, so projects relying onspring.jackson.default-property-inclusion(or an equivalent customizer) can no longer enforce it uniformly on generated types.Reproduction (for context)
Spring Boot
application.yml:Optional non-nullable String / List / Map<K,V> fields on a generated model. Response body on 7.23 omits empty values (global NON_EMPTY); on 7.24 emits "field": "", "list": [], "map": {} (field-level NON_NULL wins).
openApiNullable=false, Spring Boot 3.x, Jackson 2.x. Verified live.