Skip to main content
Logo
Overview

Spring Boot 4 Migration: What Breaks Before June 30

June 7, 2026
10 min read

If you’re running Spring Boot 3.5 in production, you have a decision to make in the next three weeks. OSS support for 3.5 ends June 30, 2026. After that date, no more free patches land in Maven Central — not for bugs, and more importantly, not for CVEs. There is no Spring Boot 3.6. The next version is 4.0, and it’s a bigger jump than the version number suggests.

I’ve now run two real apps through the 3.5 → 4.0 upgrade — a mid-size REST service and a batch-heavy internal tool — and the gap between “the changelog says X changed” and “X broke my app at 2am” is wide. This is the guide I wish I’d had before I started: what actually breaks, what the listicles get wrong, and a realistic call on whether you should even do this before the deadline.

The deadline, decoded

Spring Boot 3.5 was the last 3.x line. When the Spring team says June 30, 2026, they mean open-source end of life: the community releases stop. Commercial support (VMware Tanzu’s enterprise subscription) runs longer, but that’s a paid door, not the free one most teams walk through.

The “zombie dependency” problem is the real risk here. Even if your code never changes after June 30, your transitive dependencies keep getting CVEs. Without a maintained Boot 3.5 line, those fixes either don’t come or you backport them yourself. A single unpatched Spring Security or Jackson CVE in a public-facing service is the kind of thing that turns up in a SOC 2 audit or a pentest report with your name on it.

So you’ve got three doors: upgrade to Boot 4, buy extended support, or stay on 3.5 and accept the risk. I’ll come back to that decision at the end, because honestly it’s not automatic — the “just upgrade” advice ignores how much work 4.0 actually is.

First, the myth: no, you don’t need Java 21

This is the most common wrong claim floating around right now, and it matters because it changes your whole rollout plan. A lot of migration posts state flatly that Spring Boot 4 requires Java 21. It doesn’t.

The official baseline is Java 17. Spring Boot 4.0.6 runs on Java 17 and is tested up through Java 26. Java 21 (or 25) is recommended — Spring Framework 7 leans on it for virtual-thread improvements, GC behavior, and modern language features — but it’s not a hard floor.

Why does this distinction matter? Because if you believed the Java 21 myth, you’d scope a JDK upgrade across your dev machines, CI runners, and Docker base images into the same sprint as the framework jump. That’s a much riskier change to ship all at once. You can do the Boot 4 migration on Java 17, get it stable, and bump the JDK as a separate, lower-stakes step later. Decouple them.

Two related version notes if you’re on the edges: Kotlin needs 2.2+, and GraalVM native-image users need 25+. Those are real floors.

The break that bites first: CSRF 403s

Here’s the one that took down my REST service on the first boot, and it’s the single most common failure I’ve seen reported.

Spring Boot 4 ships Spring Security 7, which turns CSRF protection on by default. If you wrote a stateless REST API in Boot 3 and never touched CSRF config — which was completely correct, because token-based APIs don’t need it — every state-changing request (POST, PUT, DELETE) now comes back 403 Forbidden. Your GETs work fine, which makes it sneaky. Health checks pass, the app looks up, and then the first write fails.

The fix is straightforward once you know it’s CSRF: explicitly disable it for your stateless endpoints in your security config.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())  // stateless API, token auth
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

If you have a browser-session app, you probably want CSRF on — so for you this “break” is actually the framework doing the right thing. The danger is purely for API teams who never thought about it. Audit every SecurityFilterChain before you deploy, not after.

While you’re in there: WebSecurityConfigurerAdapter is gone for good. If you somehow still had one lingering from a Boot 2 era, the component-based config is now the only way.

Jackson 3: the silent serialization shift

Second most common failure, and nastier because it doesn’t throw — it just produces subtly wrong JSON.

Boot 4 makes Jackson 3 the default. This isn’t a minor bump. The package moved from com.fasterxml.jackson to tools.jackson, so any custom serializers, mixins, or ObjectMapper tuning you have referencing the old package won’t compile until you update imports. Classes got renamed too — Jackson2ObjectMapperBuilderCustomizer is now JsonMapperBuilderCustomizer.

The part that scares me more than the compile errors is the behavioral drift. Jackson 3 changed defaults around date formatting, null-field handling, and number serialization — BigDecimal output in particular. If you have API consumers parsing your responses with strict schemas, a BigDecimal that used to serialize as 10.00 and now comes out as 10 can break a downstream client without a single error on your side.

Two things save you here:

  • If a library in your tree still hard-depends on Jackson 2, the spring-boot-jackson2 compatibility module keeps it alive during the transition. Use it as a bridge, not a permanent crutch.
  • Add contract tests on your actual JSON responses before you migrate. Snapshot a few representative payloads on 3.5, then diff them after the upgrade. This catches the silent drift that unit tests miss.

Undertow is dead, RestTemplate went opt-in

If you run on Undertow, stop and plan, because this one fails hard. The Undertow starter and embedded-server support are completely removed — Boot 4 needs a Servlet 6.1 baseline, and Undertow doesn’t support it. You won’t get a friendly message; you’ll get a ClassNotFoundException at startup. Switch to Tomcat (the default) or Jetty. For most apps that’s a starter swap and a few tuning properties, but if you’d leaned on Undertow-specific config, budget real time.

Smaller but easy to miss: RestTemplate auto-configuration is now opt-in. In Boot 3 you could @Autowired a RestTemplate and it just appeared. Now if nothing declares one, that injection fails at startup. Define the bean yourself:

@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) {
    return builder.build();
}

Worth a thought while you’re touching it: if you’re writing new HTTP-client code, this is a good moment to look at RestClient instead — it’s the direction Spring is pushing.

The batch trap, and the modularization tax

If you run Spring Batch, read this twice. Boot 4 changed the default so Batch runs in-memory — job metadata is no longer persisted to a database unless you ask for it. The implication is brutal: restart-after-failure and run-deduplication quietly stop working, because there’s no job repository remembering what already ran. Your batch jobs will appear to work in testing and then re-run completed steps in production. The fix is to add spring-boot-starter-batch-jdbc, which restores the JDBC-backed job repository.

That batch change is one instance of a broader theme: modularization. Boot 4 broke the big autoconfiguration jars into smaller, focused modules. The upside is leaner builds. The cost is that features which used to ride along for free now need an explicit starter. The two that bite most teams:

  • Flyway now needs spring-boot-starter-flyway
  • Liquibase now needs spring-boot-starter-liquibase

Miss these and your database migrations silently don’t run on startup — another failure that hides until the wrong moment. Expect to spend an afternoon reconciling your pom.xml or build.gradle against the new starter layout.

Before any of this: clean up on 3.5

Here’s the step people skip and regret. Don’t jump straight from your current 3.5.x to 4.0. Do this first:

  1. Bump to the latest 3.5.x. Boot 3.5 exists partly as a bridge — it deprecates everything that 4.0 removes and gives you compiler warnings pointing at exactly what to fix.
  2. Fix every deprecation warning. All APIs deprecated across the 3.x line are removed in 4.0 — not soft-deprecated, removed. Each warning you ignore now is a compile error later, except you’ll be debugging it with fifty other changes in flight.
  3. Run spring-boot-properties-migrator. Add it as a temporary dependency and it’ll report renamed and removed config properties at startup. Pull it back out before you ship.

Doing the cleanup on 3.5 means you hit one category of problem at a time instead of all at once. The difference in debuggability is enormous.

Let the robots do the boring 80%

A straight-up manual migration is a lot of repetitive edits — import rewrites, renamed classes, property keys. This is exactly what automated recipes are good at. OpenRewrite has a Spring Boot 4 migration recipe, and Moderne (the company behind it) publishes a maintained recipe set for the 3.5 → 4.0 path that handles the mechanical bulk: Jackson package relocations, deprecated-API swaps, property renames.

My experience: the recipes get you maybe 70–80% of the way, mechanically and reliably. They will not reason about your CSRF posture, your Jackson serialization contracts, or your batch persistence needs — the judgment calls are still yours. Run the recipe, commit that diff on its own so it’s reviewable, then do the human work on top. Treat it as a power tool, not autopilot.

How long does this actually take?

Ignore anyone quoting a single number. It scales with surface area, roughly:

  • Small, modern service (one app, already clean on 3.5, no Undertow/Batch, stateless API): 2–4 days. Most of it is the CSRF audit and Jackson contract testing.
  • Mid-size app with custom security, several integrations, some Jackson customization: 1–2 weeks.
  • Large or legacy estate — multiple services, Undertow, Spring Batch, accumulated deprecations, shared internal libraries: 6+ weeks, sometimes a couple of months. The long pole is usually shared libraries that half your services depend on; you can’t move an app until its libraries support Jackson 3 and the new namespaces.

The variance isn’t about Spring’s complexity — it’s about how much deprecation debt you’ve been carrying and how tangled your internal dependencies are.

So should you upgrade by June 30?

Realistically, for most teams the answer is: start now, but don’t expect to finish by the deadline, and that’s okay if you plan for it.

  • Upgrade now if you’re a small-to-mid app with clean 3.5 hygiene. You can plausibly make the date, and you get Spring Framework 7’s features (better virtual-thread support, the new resilience and API-versioning bits) as a bonus.
  • Buy extended support if you have a large estate that physically can’t migrate in three weeks and you have public-facing services that can’t sit on unpatched CVEs. Vendors like HeroDevs sell post-EOL security patches for exactly this gap. It costs money, but it buys you a controlled migration timeline instead of a panicked one. For a regulated shop, that’s usually cheaper than the alternative.
  • Stay on 3.5 and accept the risk only for internal, non-internet-facing apps where a few months of unpatched dependencies is a tolerable, documented decision — not a default you backed into.

The genuinely bad option is the one most teams will pick by accident: do nothing, let June 30 pass, and discover three months later that you’ve been running unpatched because the upgrade kept getting bumped. If you take one thing from this: at minimum, bump to the latest 3.5.x and clear your deprecation warnings this sprint. That work is required no matter which door you eventually pick, and it’s the cheapest insurance against a rushed migration.

Spin up a throwaway branch, point your smallest service at Boot 4.0.6, and just try to boot it. Whatever fails first will tell you more about your real timeline than any guide — including this one.