16.1.2.4 Dependency Version Conflict
A focused guide to Dependency Version Conflict, connecting core concepts with practical Docker and container operations.
A dependency version conflict during a Docker build occurs when two or more required packages specify incompatible version constraints on a shared dependency, or when a build resolves to a different set of versions than what was tested locally, producing failures that range from an explicit resolver error to a more insidious, silent version mismatch that only manifests as unexpected application behavior after the build succeeds.
Explicit resolver conflicts
Many package managers detect an unsatisfiable set of version constraints directly and fail with an explicit error naming the conflicting requirements, which is the more straightforward version of this problem to diagnose:
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^18.0.0" from some-library@2.0.0
npm ERR! Found: react@17.0.2
npm ls react
Running a dependency tree inspection command directly identifies exactly which packages are requesting which versions of the conflicting dependency, which is the necessary first step before deciding how to resolve the conflict, upgrading the dependency causing the constraint, pinning to a compatible version, or in some ecosystems, using an explicit override mechanism.
Lockfile and manifest divergence
A build that resolves different dependency versions than what was tested locally, despite using what appears to be the same manifest file, often indicates the lockfile itself was not actually committed, was excluded by .dockerignore, or was not actually used during the build's install step:
docker build --progress=plain -t my-api . 2>&1 | grep -A3 "npm ci"
node_modules
package-lock.json
A .dockerignore pattern that inadvertently excludes the lockfile alongside node_modules forces the package manager to resolve versions fresh during every build, rather than reusing the exact versions the lockfile was meant to pin, which can silently introduce version drift between what was tested and what actually gets built.
Transitive dependency conflicts
The most difficult version conflicts to diagnose are often not between direct dependencies the application explicitly declares, but between transitive dependencies, packages required indirectly by other packages, where the actual conflict is several layers removed from anything visible in the top-level manifest file:
npm ls --all | grep -B2 "invalid"
pip check
Most package managers provide some form of dependency tree inspection or validation command capable of surfacing transitive conflicts directly, which is considerably faster than manually tracing through nested dependency declarations to find the actual source of an incompatibility.
Version conflicts that succeed but produce wrong behavior
Not every version conflict produces a hard installation failure; some package managers resolve a conflict by silently selecting one version that satisfies the majority of constraints while violating a more lenient one, which can produce a successful install that nonetheless results in subtly incorrect application behavior at runtime, since one or more dependencies are now running against a version they were not actually designed or tested against:
npm ls some-library
some-library@1.2.0 (requested: ^2.0.0)
A version installed that does not actually satisfy what was requested, visible directly through a dependency tree inspection, is a strong, specific signal worth investigating even when the build itself reported no error, since this kind of silent resolution is exactly the scenario that produces confusing, hard-to-trace runtime bugs unrelated to anything visibly wrong in the build output.
Pinning to avoid future conflicts
For dependencies known to be sensitive to version drift, particularly across transitive dependency chains, explicitly pinning to exact versions rather than allowing a version range reduces the chance of an unexpected, future conflict introduced by an upstream package publishing a new release that happens to violate an existing constraint:
{
"dependencies": {
"some-library": "2.1.3"
}
}
some-library==2.1.3
This trades some of the convenience of automatic minor and patch updates for considerably more predictable, reproducible builds, which is generally the more appropriate trade-off for a production application's direct dependencies, even if a looser range remains acceptable for development-only tooling.
Resolving conflicts with override mechanisms
Several package managers provide an explicit override or resolution mechanism for forcing a specific version of a transitive dependency when the natural resolution process cannot satisfy every constraint on its own:
{
"overrides": {
"some-transitive-dep": "3.0.1"
}
}
resolutions:
"some-transitive-dep": "3.0.1"
This should be used deliberately and documented clearly, since it is effectively overriding the package manager's own conflict resolution logic, which carries some risk if the forced version is not actually fully compatible with everything depending on it, even though it satisfies the version constraint syntactically.
Reproducing the conflict outside the build for faster iteration
Iterating on a version conflict by repeatedly running full Docker builds is considerably slower than reproducing the same dependency resolution locally, outside a container, where the feedback loop for trying different version constraints or override values is much faster:
rm -rf node_modules package-lock.json
npm install
Once a working set of versions and constraints is found locally, the same lockfile or manifest changes can be committed and verified with a single, final Docker build, rather than using the build itself as the primary tool for iterating on the resolution.
Common mistakes
- Excluding the lockfile through an overly broad
.dockerignorepattern, forcing fresh dependency resolution during every build rather than reusing tested, pinned versions. - Treating a successful build as proof that no version conflict exists, without checking whether a silent, partially-satisfying resolution actually occurred.
- Only investigating direct, top-level dependencies for a version conflict, missing a transitive dependency several layers removed as the actual source.
- Using version ranges for dependencies known to be sensitive to drift, rather than pinning to exact versions for more reproducible builds.
- Iterating on a complex version conflict using full Docker builds as the primary feedback loop, instead of reproducing the same resolution locally for faster iteration.
Dependency version conflicts range from an explicit, immediately visible resolver error to a silent, successfully-resolved mismatch that only manifests as confusing runtime behavior, and resolving them reliably requires inspecting the actual dependency tree directly, ensuring the lockfile is genuinely present and used during the build, and pinning sensitive dependencies explicitly rather than relying on a version range to resolve consistently across every build.