Maven has no lockfile
npm writes a lockfile by default. Cargo does. Poetry does. Even Gradle has one you can turn on. Maven has none.
A pom.xml records what you asked for. What you get depends on
what the repositories hold on the day of the build. That is a
build I will not ship.
The gap is acknowledged upstream. Tamas Cservenak, a Maven committer, wrote in December 2025 that a project can end up on version ranges through a transitive dependency it does not control, and that the improvements in Maven 4 "still do not solve lack of lockfile". He is right. "Just don't use ranges" is not an answer. A transitive POM you do not control can put you on one.
Both cases below were reproduced with Maven 3.9.16 against a local
file:// repository.
A version range moves
<dependency>
<groupId>com.example</groupId>
<artifactId>greeting</artifactId>
<version>[1.0,2.0)</version>
</dependency>
The repository holds greeting 1.0.0, and the build uses it. Then
1.1.0 is published, and the next build takes it without a warning:
$ mvn dependency:list
com.example:greeting:jar:1.1.0:compile -- module greeting (auto)
Same pom.xml. Different jar. Pinning your direct dependencies does
not prevent this. A range in a dependency's POM moves the same way
unless you also pin that artifact in dependencyManagement, and a
SNAPSHOT moves on every deploy. The enforcer's banDynamicVersions
rule can ban them, but it does not record what a build resolved. A
ban is a policy. A lockfile is a record of what you actually built.
With an rv.lock written before 1.1.0 appeared, CI fails with exit
status 7 and names the change:
$ rv sync --frozen
✗ lockfile mismatch: dependencies would change from current lockfile. Run 'rv sync' to update rv.lock, or remove --frozen flag.
Changed entries:
~ [pom.xml] external com.example:greeting:1.0.0 -> com.example:greeting:1.1.0 (linux-x86_64)
Taking 1.1.0 is then an rv sync and a reviewed change to rv.lock.
Someone has to look at that diff on purpose.
A release changes its bytes
Maven Central never lets a release change. An internal repository
does if redeploy is enabled or an admin deletes the artifact and
someone uploads it again. Here greeting-1.0.0.jar is replaced along
with its .sha1, so the two still match. A build from an empty local
repository, as on most CI runners, gets the new jar, and
mvn dependency:list prints the same line as before.
Same coordinates. Different bytes. The version string did not move.
rv.lock holds a SHA-256 of every artifact and POM, taken when it
was written. From an empty cache, rv sync --frozen downloads again
and compares:
$ rv sync --frozen
Changed entries:
~ [pom.xml] external checksum changed for com.example:greeting:1.0.0 (linux-x86_64)
rv compares bytes only when it downloads them, so this check needs a
clean cache. Maven 3.9 can check on every build with trusted
checksums, which verify each resolved artifact, including those
already in ~/.m2, against a checksum file kept in the project.
rv export-checksums writes that file from rv.lock (Maven reads
it with the summary-file source set to SHA-256 and
originAware=false). Trusted checksums cover bytes. They do not
record which version a range picked. You need both.
rv
rv is a native CLI I built for this. It reads the
pom.xml you already have, resolves it the way Maven does (parents,
imported BOMs, profiles, exclusions, nearest-wins mediation) and
writes rv.lock with each artifact's exact version, source
repository and hashes. A multi-module reactor gets one lock at the
root. Maven still runs the build:
rv sync # resolve and write rv.lock
rv export-m2 # copy the locked artifacts into ~/.m2/repository
mvn -o package # build offline against them
The closest prior art is chains-project's maven-lockfile, a Maven plugin that records versions and checksums of the resolved graph, plugins included. Running inside Maven, it agrees with Maven by construction. That is a real advantage. rv is a separate resolver and needs no JVM; the cost is that it has to show it resolves the same way.
I compare it with mvn dependency:list from Maven 3.9.16 on 48
open-source projects, among them apache/dubbo, spring-ai, netty,
langchain4j and jenkins. 46 lock, and on all 1,309 modules where
both tools produced a list, the external dependency sets are
identical. The two failures are expected: apache/struts needs a
repository declared only in a transitive POM, which rv does not
trust by default, and flyway/flyway needs GitHub Packages
credentials, without which Maven fails as well.
On spring-petclinic (171 dependencies, warm cache), rv sync --offline takes 0.27 s and mvn -o dependency:list 0.99 s, median
of five runs on one machine. The tables are in
docs/verification.md.
Limits
- Build plugins are not locked.
mvn -ostill needs them in~/.m2, usually from one earlier online build. - The comparison covers each module's external
group:artifact:classifier:versionset, not scopes or graph edges. - It has not been run against your company's repository manager. A POM on which rv and Maven disagree is a bug worth reporting.
curl -fsSL https://raeva.io/install.sh | sh # or: brew install raeva-io/tap/rv
cd your-maven-project && rv sync
On Windows, install with irm https://raeva.io/install.ps1 | iex.
PowerShell aliases rv to Remove-Variable, so type rv.exe there.
The source is at raeva-io/raeva, under MIT or Apache-2.0.