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.
ยท 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.

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*'./gradlew -q dependencyInsight --dependency jackson-databind --configuration runtimeClasspathWhat 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.
| Bucket | What it is | Examples | What it costs you |
|---|---|---|---|
| 1. Shaded, therefore harmless | Libraries carrying their own internalised copy, which cannot conflict with yours | The AWS SDK for Java since 2.17; Flink's flink-shaded-jackson; Iceberg's Spark and Flink runtime bundles | Nothing. Count them, then set them aside |
| 2. Yours to change | Direct Jackson 2 use in your own source | Your mappers, custom serializers and configuration classes | Real but bounded, well documented and largely automatable |
| 3. Upstream-blocked | Third parties compiled against unshaded Jackson 2 that expose it across their API | iceberg-core, where Jackson types leak into the parsers, JsonUtil and the REST layer | This 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.

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>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=trueRead 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();
}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.
| Default | Jackson 2 | Jackson 3 | What changes on the wire |
|---|---|---|---|
WRITE_DATES_AS_TIMESTAMPS | enabled | disabled | Dates serialize as ISO-8601 strings, not numbers |
SORT_PROPERTIES_ALPHABETICALLY | disabled | enabled | Field order changes |
FAIL_ON_TRAILING_TOKENS | disabled | enabled | Payloads you used to tolerate now throw |
FAIL_ON_NULL_FOR_PRIMITIVES | disabled | enabled | Nulls into primitives fail instead of defaulting |
FAIL_ON_UNKNOWN_PROPERTIES | enabled | disabled | Unknown fields pass silently instead of erroring |
READ_ENUMS_USING_TO_STRING and its write twin | disabled | enabled | Enums cross the wire as toString(), not as the constant name |

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.jackson2for the Jackson 2 support andspring.jacksonfor 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-defaultskeeps 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 (IOExceptionaround 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.

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
- Migrating to Jackson 3 (opens in a new tab), FasterXML
- Introducing Jackson 3 support in Spring (opens in a new tab), Spring
- Spring Boot 4.0 Migration Guide (opens in a new tab), spring-projects wiki
- Filtering the dependency tree (opens in a new tab), Apache Maven
- Viewing and debugging dependencies (opens in a new tab), Gradle
- The AWS SDK for Java 2.17 removes its external dependency on Jackson (opens in a new tab), AWS
- The Jackson 3 problem in Apache Iceberg (opens in a new tab), Datalakehouse Hub
- flink-shaded (opens in a new tab), Apache Flink
More from CipherCru
Other thinking on how technology decisions get made and delivered.
Get new writing by email
Occasional notes on technology decisions, delivery and modernization. No more than once a month.
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.

