Why Maven Picks the 'Wrong' Dependency Version — and How to Actually Control It
Maven resolves version conflicts by nearest definition, not newest version. Here's how the mediation rule actually works, how to see it with dependency:tree, and how dependencyManagement gives you back control.
02 Sept 2026, 22:21 UTC

You bump a library in one module, run the build, and the tests fail with a NoSuchMethodError — the classic symptom of a version conflict. You check the POM: the version you wanted is right there. So why did Maven resolve something else?
The answer is Maven's dependency mediation rule, and once you understand it, this class of bug becomes easy to diagnose and prevent. The short version: Maven doesn't pick the newest version of a conflicting dependency. It picks the nearest one in the dependency tree. That's a deliberate design choice, and it has real consequences for how you should structure multi-module projects.
Nearest definition wins, not newest
When two paths in the dependency graph lead to the same artifact with different versions, Maven walks the tree from your project root and keeps whichever declaration it reaches first — the one with the shortest path from the root. If both paths are the same length, the first declaration encountered wins.
Concretely: if your project directly depends on commons-lang3:3.12.0, and a transitive dependency pulls in commons-lang3:3.14.0, you get 3.12.0. The older version wins because your direct dependency is one hop from the root and the transitive one is two hops away. This surprises people who assume Maven behaves like npm or Gradle's default of picking the highest version.
The rationale is predictability: versions you explicitly declared should beat versions you never asked for. But it means a stale direct dependency can silently pin an old library across your whole build.
See the conflict before it bites you
The single most useful diagnostic is the verbose dependency tree. Run this from the module that's misbehaving (no special permissions needed; it only reads your local repository and configured remotes):
mvn dependency:tree -DverboseWith -Dverbose, the output includes entries marked omitted for conflict with X.Y.Z, showing every version that lost the mediation battle and which version won. If you see the version you expected listed as \"omitted,\" you've found your problem. You can also narrow the output when the tree is huge:
mvn dependency:tree -Dverbose -Dincludes=org.apache.commons:commons-lang3One caveat: the verbose tree's conflict annotations reflect the mediation rule, but they don't explain why a path exists. For that, trace which direct dependency drags in the transitive one — the tree indentation shows the chain.
Centralize versions with dependencyManagement
In a multi-module build, the maintainable fix is to stop scattering <version> tags across module POMs. Declare versions once in the parent POM's <dependencyManagement> section:
<!-- parent pom.xml -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
</dependencies>
</dependencyManagement>Child modules then declare the dependency with no version at all, and inherit the managed one:
<!-- module pom.xml -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>dependencyManagement> does two jobs. It supplies versions for direct dependencies that omit them, and it overrides the versions of transitive dependencies — so a transitive commons-lang3:3.9.0 gets forced up to your managed 3.14.0. That second behavior is the real weapon against version drift, because it applies even to artifacts you never declare directly.
Note the asymmetry: a module that declares an explicit <version> on a direct dependency overrides the managed version. That's occasionally useful as an escape hatch, but in practice it's usually a mistake — someone pinned a version locally and now the parent's central management silently doesn't apply to that module. Worth grepping for in code review.
Verify the effective result
Because POMs inherit and merge, what you wrote isn't always what Maven uses. Two checks close the loop:
mvn help:effective-pomThis prints the fully merged POM for the current module, including inherited dependencyManagement entries. Confirm your managed version appears there. Then re-run mvn dependency:tree and check that the resolved version matches and no unexpected \"omitted for conflict\" lines remain for that artifact. These commands are read-only and safe to run anywhere, including CI.
The trade-off you accept
Forcing a transitive dependency to a newer version via dependencyManagement is not free. The library that requested the older version was presumably tested against it; you're betting on semantic versioning and backward compatibility. Usually that bet pays off, especially for patch and minor bumps, but a forced upgrade can surface subtle behavioral changes that only appear at runtime. The mitigation is boring but real: after centralizing a version, run the full integration test suite, and treat major-version forcing with suspicion.
There's also a Maven 3.x wrinkle with snapshots: if any managed version is a -SNAPSHOT, resolution can shift between builds as remote snapshots update, which undermines the whole point of centralization. Keep managed versions to releases, and let CI enforce it.
What to do Monday morning
Pick your most-dependency-heavy module and run mvn dependency:tree -Dverbose. Every \"omitted for conflict\" line is a decision Maven made for you. For each one, decide deliberately: move the version into the parent's dependencyManagement, remove the stale direct dependency that's pinning an old release, or accept the mediation outcome with eyes open. An hour of this usually eliminates the entire category of \"works in module A, breaks in module B\" build surprises.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.