Skip to content
CipherCruCipherCru

Menu

Legacy modernization

What Spring Boot 4 Breaks: The Jackson 3 Blast Radius

Every Spring Boot 4 migration guide is written against a clean project. Yours is not clean. The API change is a weekend, and the propagation through a dependency tree you did not build is the quarter.

Portrait of Amit AgarwalAmit AgarwalFounder, CipherCru

ยท 10 min read

Every Spring Boot 4 migration guide is written against a clean project. Yours is not clean.

The API change is a weekend. The propagation through a dependency tree you did not build is the quarter. Here is how to measure it before you commit to a date, and how to split it into two releases your board will approve.

Why don't the published estimates apply to you?

Because the published breaking-change list is the easy half. Undertow support removed, Flyway and Liquibase needing explicit starters, @MockBean becoming @MockitoBean: all mechanical, all enumerable, and much of it automatable. The estimate does not die there. It dies in Jackson.

Spring Boot 4 defaults to Jackson 3, and Jackson 3 changed its coordinates. Both the Maven group ID and the Java package move from com.fasterxml.jackson to tools.jackson.

One deliberate exception explains why this looks smaller than it is. The annotations stay put: jackson-annotations keeps the group ID com.fasterxml.jackson.core and the package com.fasterxml.jackson.annotation, and Jackson 3 still depends on the 2.x artifact. Your annotated DTOs compile clean and look untouched, which makes them the least reliable signal in the whole migration.

A table headed "The annotations stayed. Everything around them moved." Maven group ID: com.fasterxml.jackson becomes tools.jackson, moved. Java package: com.fasterxml.jackson becomes tools.jackson, moved. Annotations package: com.fasterxml.jackson.annotation, unchanged, stays. Format annotations: com.fasterxml.jackson.dataformat becomes tools.jackson.dataformat, moved. Minimum Java: 8 becomes 17, raised. A footnote reads: annotated DTOs compile clean and read as unaffected. They are the least reliable signal in the entire migration.
What moved and what did not. Source: FasterXML, MIGRATING_TO_JACKSON_3.

Because the coordinates differ, Jackson 2 and Jackson 3 do not collide. Both resolve, both load, and nothing fails at build time.

What they cannot do is interoperate. A Jackson 2 ObjectMapper and a Jackson 3 JsonMapper are unrelated types, and so are the two JsonNode classes. Any library still compiled against Jackson 2 keeps its own serialization world, with its own defaults, and your Spring configuration does not reach it.

Your build is green and your boundary is broken. That is why an estimate borrowed from a clean-project guide tells you nothing.

How do you audit the blast radius?

Run two commands against every module, and quote the pattern so your shell does not expand it.

mvn dependency:tree -Dincludes='com.fasterxml.jackson*,tools.jackson*'
Maven: both coordinate namespaces, in one pass per module
./gradlew -q dependencyInsight --dependency jackson-databind --configuration runtimeClasspath
Gradle: which dependency is dragging in jackson-databind, and why

What comes back is a forest, not a list, and the indentation is the whole point. A top-level jackson-databind is trivial: you own it, you bump it.

What you are hunting for is the third and fourth level of indentation, where a library you chose four years ago drags in a Jackson it never mentions in its README. Those lines carry a parent, and the parent is the constraint.

Then sort every hit into exactly three buckets. The counts in these buckets are your estimate. Nothing else is.

The three buckets, and which one is actually your estimate
BucketWhat it isExamplesWhat it costs you
1. Shaded, therefore harmlessLibraries carrying their own internalised copy, which cannot conflict with yoursThe AWS SDK for Java since 2.17; Flink's flink-shaded-jackson; Iceberg's Spark and Flink runtime bundlesNothing. Count them, then set them aside
2. Yours to changeDirect Jackson 2 use in your own sourceYour mappers, custom serializers and configuration classesReal but bounded, well documented and largely automatable
3. Upstream-blockedThird parties compiled against unshaded Jackson 2 that expose it across their APIiceberg-core, where Jackson types leak into the parsers, JsonUtil and the REST layerThis is the estimate. Wait, fork, shim or defer

A warning on Bucket 1: check, do not assume. Apache Spark does not shade Jackson, which is exactly why it is a well-known source of version conflicts. Flink does. Getting that wrong in either direction moves a library into the wrong bucket.

The classification is also the part you cannot delegate to a tool. A scanner tells you Jackson 2 is present. It cannot tell you whether the library holding it shades it, exposes it across its API, or merely reads a config file with it.

That judgement takes an engineer who knows the library, roughly an hour per entry. It is the highest-leverage hour in the migration, because a Bucket 3 entry misfiled as Bucket 1 is the surprise that reaches production.

The number you take to planning is the size of Bucket 3. Everything else is schedulable work. Bucket 3 is dependency risk with somebody else's release calendar attached.

Can you stage it across two releases?

Yes, and this is the part the migration guides omit. Spring Boot 4.0 ships dependency management for both Jackson 2 and Jackson 3. Jackson 3 gets the auto-configuration, and Jackson 2 auto-configuration is retained in deprecated form specifically to support a gradual migration.

Spring publishes its order of preference: migrate fully to Jackson 3; or migrate to Jackson 3 with spring.jackson.use-jackson2-defaults set; or use Jackson 2 temporarily as a stepping stone to Jackson 3.

A three-step timeline headed "Spring Boot 4 manages both Jacksons. Use that." Release 1, framework only: take Spring Boot 4 and keep Jackson 2 by excluding spring-boot-jackson and depending on spring-boot-jackson2 instead, so you get the supported framework, the module restructuring and the security posture now, decoupled from your upstreams' release calendars. Release 2, coordinates with defaults held: move to Jackson 3 with spring.jackson.use-jackson2-defaults set, so packages change while the emitted JSON does not. Release 3, flip the defaults: drop use-jackson2-defaults deliberately, with contract tests around every payload change, as the only release that alters what consumers receive. A footnote says that if Bucket 3 is empty you can collapse these into one release and will have lost nothing by checking.
The staging path. A large Bucket 3 is survivable if the framework upgrade and the Jackson move ship separately.

That third option is what makes a large Bucket 3 survivable. You take Spring Boot 4 now, getting the supported framework and the security posture, and sequence the Jackson move separately, once your upstreams ship.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-json</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-jackson</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-jackson2</artifactId>
</dependency>
Release 1: Spring Boot 4 with Jackson 2 retained

Configuration properties move with the module: use spring.jackson2 in place of spring.jackson while you are on the Jackson 2 support.

spring.jackson.use-jackson2-defaults=true
Release 2: Jackson 3 coordinates, Jackson 2 behaviour

Read that property narrowly. It makes the auto-configured JsonMapper align as closely as possible with Jackson 2's defaults in Spring Boot 3. It does not reach a mapper you construct yourself.

Teams that model this as one atomic migration produce a number their board rejects. Teams that split it ship two smaller releases.

Which changes are silent?

The ones your test suite may not catch, which is why a clean compile means very little here.

Exceptions became unchecked. The base type is now JacksonException, it extends RuntimeException rather than IOException, and every Jackson exception is unchecked. JsonMappingException is now DatabindException, and JsonParseException is now StreamReadException.

That matters most where you never named a Jackson type at all.

// Jackson 2: readValue threw JsonProcessingException, an IOException
try {
    return mapper.readValue(payload, Order.class);
} catch (IOException e) {
    // Jackson 3: nothing here catches a parse failure any more.
    // It is unchecked, so the compiler does not tell you either.
    return Order.empty();
}
The catch block that still compiles and stops catching

Error handling you believed was in place quietly is not. Grep every catch of IOException around a mapper call, and every JsonProcessingException, JsonMappingException and JsonParseException, as a first-class work item.

Serialization output changed. Six defaults flipped, and each one changes bytes your consumers receive without a line of your code changing.

Jackson defaults that changed, and what a consumer sees
DefaultJackson 2Jackson 3What changes on the wire
WRITE_DATES_AS_TIMESTAMPSenableddisabledDates serialize as ISO-8601 strings, not numbers
SORT_PROPERTIES_ALPHABETICALLYdisabledenabledField order changes
FAIL_ON_TRAILING_TOKENSdisabledenabledPayloads you used to tolerate now throw
FAIL_ON_NULL_FOR_PRIMITIVESdisabledenabledNulls into primitives fail instead of defaulting
FAIL_ON_UNKNOWN_PROPERTIESenableddisabledUnknown fields pass silently instead of erroring
READ_ENUMS_USING_TO_STRING and its write twindisabledenabledEnums cross the wire as toString(), not as the constant name
A table headed "A green build says nothing about your payloads", listing five Jackson defaults that changed between Jackson 2 and Jackson 3: WRITE_DATES_AS_TIMESTAMPS from enabled to disabled, so dates serialize as ISO-8601 strings rather than numbers; SORT_PROPERTIES_ALPHABETICALLY from disabled to enabled, so field order changes; FAIL_ON_TRAILING_TOKENS from disabled to enabled, so previously tolerated payloads now throw; FAIL_ON_NULL_FOR_PRIMITIVES from disabled to enabled, so nulls into primitives fail; FAIL_ON_UNKNOWN_PROPERTIES from enabled to disabled, so unknown fields pass silently. A footnote notes that Jackson exceptions are now unchecked and no longer extend IOException, so old catch blocks still compile and silently stop catching.
Read this as a contract change, because that is what it is. Source: FasterXML, MIGRATING_TO_JACKSON_3.

The enum pair is the one that catches people. If you publish JSON to external consumers, persist it, hash it or snapshot-test it, your payloads change shape without a single line of your code changing.

Mappers are immutable now. ObjectMapper still exists in Jackson 3, but mappers are built with builders, and the factory-argument constructor is gone. So new ObjectMapper(new YAMLFactory()) becomes new YAMLMapper(), which touches every configuration class you have.

Spring renames come with it: Jackson2ObjectMapperBuilderCustomizer becomes JsonMapperBuilderCustomizer, JsonObjectSerializer becomes ObjectValueSerializer, @JsonComponent becomes @JacksonComponent, and @JsonMixin becomes @JacksonMixin.

One more, and it costs nothing if you have already moved: Jackson 3 requires Java 17, up from Java 8. That is the same floor as Spring Boot 4 itself.

What's the catch with staying on Jackson 2?

You are deferring, not avoiding, and you are deferring onto a deprecated path.

  • Jackson 2 support in Spring Boot ships deprecated and will be removed in a future release. No version has been announced, and Spring's own tracking issues discuss delaying removal, but the direction is settled.
  • You carry two configuration vocabularies while you are there, spring.jackson2 for the Jackson 2 support and spring.jackson for Jackson 3.
  • Library authors will migrate on their own schedule, so Bucket 3 shrinks whether you act or not. Check each entry for an open Jackson 3 issue and a release target, which turns your guess into a schedule.
  • The behaviour change is still ahead of you. Holding defaults with use-jackson2-defaults keeps payloads stable, but the release that flips them still needs contract tests.

Staying is the right call when Bucket 3 is large and your upstreams are moving. It is the wrong call if it becomes the permanent answer.

What should you do this week?

  • Run both dependency-tree commands across every module.
  • Classify every hit as shaded, yours, or upstream-blocked, budgeting about an hour per unfamiliar library.
  • For each Bucket 3 entry, check whether upstream has an open Jackson 3 issue and a release target.
  • Grep for catch (IOException around mapper calls, and for the three renamed Jackson exception types.
  • Estimate two releases, not one: Spring Boot 4 with Jackson 2 retained, then Jackson 3 with defaults staged.
A table headed "The mechanical rows are a weekend. The last three are the quarter." Removed or renamed APIs, mechanical, resolved by OpenRewrite recipes. Starter restructuring, mechanical, OpenRewrite recipes, with Flyway and Liquibase needing explicit starters. Test annotations, mechanical, OpenRewrite recipes, with MockBean becoming MockitoBean and MockMvc no longer auto-provided. Mapper construction, mechanical but manual and bounded, because mappers are immutable and format mappers are now required. Exception handling, judgement, manual audit, because exceptions are unchecked and old catch blocks stop catching. Serialization contract, judgement, manual plus contract tests, because flipped defaults change payload shape. Bucket 3 dependencies, blocked, on the upstream release calendar: you cannot fix these, so wait, fork, shim or defer.
Automation covers the top of this table, not the bottom. The last three rows are where the quarter goes.

If Bucket 3 is empty, collapse the two releases back into one. You will have lost nothing by checking.

Publish your Bucket 3 count in the planning document. It is the one number in this migration that is genuinely yours, and the only one that survives contact with the dependency tree.

Deciding whether to do this migration at all is a separate question, with three answers: see the Spring Boot 3.x end-of-life decision tree. If you plan to put AI agents on the mechanical work, read this first.

Frequently asked questions

Do Jackson 2 and Jackson 3 conflict on the same classpath?

No. They use different group IDs and different root packages, so both resolve and both load. Spring Boot 4 relies on this: a Jackson 2 ObjectMapper can run alongside auto-configured Jackson 3. What they cannot do is interoperate, because the mapper and JsonNode types are unrelated on each side.

Does Jackson 3 require Java 17?

Yes. Jackson 3 raises its baseline from Java 8 to Java 17. That is the same minimum Spring Boot 4 requires, so if you are already planning the framework upgrade it costs you nothing extra. If you are still on Java 11 or earlier, it is the same cost, not an additional one.

Can I move to Spring Boot 4 without moving to Jackson 3?

Yes, temporarily. Exclude spring-boot-jackson from the starter that brings it in, depend on spring-boot-jackson2 instead, and use spring.jackson2 properties. Spring ships that support deprecated and will remove it in a future release, so treat it as a stepping stone with a date, not a destination.

What does spring.jackson.use-jackson2-defaults actually do?

It makes the auto-configured JsonMapper behave as closely as possible to Jackson 2's defaults under Spring Boot 3, so your coordinates move while your payloads hold their shape. It applies to the auto-configured mapper only. Any mapper your code builds itself keeps whatever defaults you gave it.

How long does the dependency audit take?

The commands take minutes. The classification takes about an hour per unfamiliar library, because deciding whether a library shades Jackson, exposes it across its API, or merely reads config with it is a judgement no scanner makes. For most estates the whole audit is a few days, and it is the cheapest few days in the project.


The one number worth publishing is your Bucket 3 count. Every other figure in a Spring Boot 4 estimate is borrowed from someone else's codebase.

Sources

Get new writing by email

Occasional notes on technology decisions, delivery and modernization. No more than once a month.

Unsubscribe in one click.

Need the Bucket 3 count for your estate?

We run the audit across every module, classify each hit as shaded, yours or upstream-blocked, and hand back the count with the upstream positions that go with it.

Strictly necessaryEssential for the site to function: page navigation, security, session management, and remembering the cookie choices you make here.
Always on
FunctionalRemembers choices you make, such as language, region or display preferences, so the site opens the way you left it.
Performance and analyticsPerformance and analytics cookies show us how the Website is used: which pages are visited, how long is spent on them, where visitors came from, and what errors occur. They are set by Google Analytics and by HubSpot, whose cookies also link the pages you viewed to any enquiry you later send us.
Marketing and targetingTracks browsing activity to measure advertising and show relevant ads. We set none of these today, and will not without your opt-in.