PSA: Please specify precise dependency versions in Cargo.toml

When you specify dependency requirements, please include the patch version:

serde = "1.0.130"  # good!

Cargo works well with versions specified this way. It is future-proof: the specified version is only the the minimum version, and still allows upgrades of minor versions. Cargo also unifies versions with the same semver-major number, so when two crates specify different versions, the higher version will be used for both (note that Cargo's behavior is quite different from npm's — precise versions are not a good idea in npm, but are fine in Cargo).

But DO NOT use imprecise depdendency requirements like:

serde = "1"  # bad

Such overly-flexible version is very likely to be wrong, because it allows Cargo to use a completely different version from the version that you were using and testing against. It's easy to accidentally depend on a feature added sometime after the x.0.0 version. The crates-io ecosystem has popular crates that add new public APIs even in "patch" releases, so even a "1.0" requirement isn't specific enough, despite intentions of the semver spec.

I know people use vague versions with good intentions to give flexibility to downstream users, but this backfires! Your crate may get a 5-year-old version of a dependency you've never tested. If you want to be maximally flexible with the versions you specify, please actually test them! (preferably in CI):

cargo +nightly -Z minimal-versions update
cargo test --all --all-features

or to test a specific downgraded dependency (and this works on stable):

cargo update -p serde --precise 1.0.0
cargo test --all --all-features

You may surprised how many crates following a "best practice" of imprecise versions end up specifying versions that are too low, and are incompatible.

Cargo may pick older versions, even in normal circumstances. It's not a bug, but a feature. If an existing project has a lockfile and adds your crate, Cargo will keep old versions of existing dependencies as long as they're "compatible". When you specify maximally "compatible" requirements, you will get the outdated versions.

Vague versions also create a second, counter-intuitive problem. You may be specifying very old dependency versions thinking of users of old Rust versions that can't upgrade their dependencies. The problem is that keeping less-than-latest dependencies in Cargo is hard. Without an existing carefully-preserved antique lockfile, it's a game of whack-a-mole of locking down versions of a dependency of a dependency. People who had to do it rightfully hate it. It would be sooo much easier with the -Z minimal-versions option, but ironically, the imprecise versions break this feature! Vague versions are not helping old Rust users: they're breaking the best tool they could have.

Some people are worried that bumping dependencies to the very latest version can break if the latest version of a dependency is yanked. It doesn't break! Users who have a lockfile won't notice a thing. Users who don't have a lockfile, will temporarily get an older version of your crate instead. But solution to this is simple: specify a version one less than the latest (or some version that is a few weeks old). This mitigates the risk of the latest version getting yanked, without creating too much risk of a too-old version breaking the build.

It's actually pretty safe to bump deps to very latest versions:

cargo install cargo-edit
cargo upgrade

If you do this at the beginning of your development cycle (that may take weeks or months), by the time you release your crate these versions won't be so cutting-edge latest any more, but will be the best versions you've actually used and developed against.

Please, let's fix the crates ecosystem to work with -Z minimal-versions. Specify dependency versions you've actually used and tested. If you insist on allowing many-years-old versions, don't make unverified assumptions — test them!

47 Likes

Out of curiosity, are there any tools which will help you find which minimum version your code is compatible with in practice?

I'm thinking an automated method of

for version in available_versions("serde", "1") {
  cargo update --package serde --precise $version
  cargo test --all --all-features
}

Here's an example of how you can verify that your crates support the specified minimum versions: link

11 Likes

Yes, I try to do this for my few libraries and apps.

I also declare an MSRV, so I add the line

rustup default whatever.the.msrv.is

to the above recipe.

2 Likes

My strategy is to use the latest precise version at the time I add a dependency to a crate, and generally stick with that unless I'm upgrading to a semver-incompatible version. As far as I can tell this should mostly avoid the worst problems of imprecise dependency versions.

I really dislike the advice to have cargo upgrade bump the version requirements without any regards for whether that's actually needed and I don't think you should keep pushing that angle.

It also seems like the tagline here of "avoiding imprecise versions" will be confusing for many; it's not as if depending on serde 1.0.1 is magically better than depending on 1.

I recently did a PR where I reset cargo upgraded dependency requirements to their minimal versions, and the imprecise version worked for most of the dependencies, including serde and serde_json. The only crates that didn't work there where async-std (which needed 1.5, from 2 years ago) and chrono (which needed 0.4.1, from 4 years ago).

4 Likes

These are the 2 extremes though. I've gotten into the habit of specifying the MAJOR.MINOR versions in Cargo.toml and let cargo figure it out from there, the idea being that patch versions are meant for bug fixes only and thus not supposed to change the APIs of a crate (this also implies that it's potentially problematic if a crate author does change the APIs in a patch release).
How would my strategy play out here?

3 Likes

In this specific case it seems to be exactly equivalent to the second one, since versions 1 and 1.0 are equivalent semver-wise.

Indeed, but mostly it'll be something like 1.x for nonzero x.
The only reason I'll generally put 1.0 in there is if that's the highest, approximately most bug-free & feature-rich version out released to date¹. And given that there isn't some 1.1 by definition and I regularly do a cargo update, there isn't much room for confusion I'd say.

¹A good example of this is serde, which is currently at 1.0.over_nine_thousand (ok that's tongue-in-cheek but the point stands).

"1.0" may not be enough:

  • if the crate adheres to semver, it's technically fine, but then 1.0.x releases are bug fixes, and there's little benefit in asking for more bugs (who knows, maybe these bugs did actually affect your crate?)

  • if the crate does not adhere to semver, then you don't know what you're getting, and you need to treat every version with suspicion. Specifically, serde and most 1.0.x crates by the same author don't adhere to the semver rules, and add new significant APIs in patch releases, so you need to deal with the case of patch versions being important for compatibility.

As for automatically checking compatibility, I don't think there's a good tool for it yet. There's cargo semverver, but I've found it very difficult to compile and unstable. There's a new promising approach of using Rustdoc JSON to extract crate's public API. I'll try to integrate it with lib.rs, but I'm afraid I'll ruffle some feathers if I start telling crate authors when they broke semver :slight_smile:

6 Likes

Quite true! That's what the cargo update once in a while is meant to address.

That's unfortunate, automatic verification of semver adherence (and then ensuribg everyone uses that eg by inclusion into cargo and/or rustc, whichever is more appropriate) would allow for the kinds of assumptions that are now being undermined.

Sounds like a great feature!

I think in your messaging around "breaking semver" I think you should separate out the notions of (a) not breaking existing API in new versions and (b) only adding API in minor versions (for non-zero major versions).

6 Likes

What's the practical difference between

cargo +nightly -Z minimal-versions update

and

cargo +nightly -Z minimal-versions generate-lockfile

?

That's a very good point. It changed my mind on this issue (up to this point, I was adhering to major.minor for post 1.0, and 0.faux-major.faux-minor for pre 1.0). I now agree that "the precise version of the oldest dependency you are compatible with" is the right guideline here.

5 Likes

One word less to type? They're probably equivalent.

I suspect update will let you keep any yanked versions, while generate-lockfile tries to resolve all dependencies from scratch.

1 Like

I find your arguments persuasive, and I'll use precise versions from now on.

3 Likes

That's not necessarily a bad thing.

I think 95% of crate authors would be thankful for you detecting accidental semver breaks.

Plus you are providing a bit of pressure to conform. Cargo already expects version numbers to be semver-compatible so not following semver is already setting your users up for a bad time, it's just that cargo publish doesn't explicitly warn you about it.

For the 5% that don't want to follow semver, you can always add an option to the metadata section in Cargo.toml to opt out of the passive-aggressive "semver-incompatible" label.

3 Likes

Is there any official documentation for Rust that makes this point a hard requirement? I didn't know that "minor" vs "patch" version for post-"1.0" crates had much/any actual significance for Rust crates beyond the option that you can use them to mean different things if you want.

2 Likes

Well, if I understand correctly, this is simply a part of semver, and for post-"1.0" crates Rust (or, more precisely, Cargo) does expect them to follow semver as-is.

upd: Cargo book, in particular, explicitly documents "adding API", e.g. adding new public items, as minor (not patch) change. However, the same page states that

This guide does not take a stance on how each individual "minor change" should be treated, as the difference between minor and patch changes are conventions that depend on the nature of the change.

...so, I'm afraid, this is not a policy, but just a common conscience of maintainers.

1 Like

As far as I understood this page so far is that itʼs only about differentiating “major vs. minor”, the main point of this list is to teach which changes are and aren't breaking changes in Rust.


Given that breaking changes on major versions are a huge problem in the way they interact with cargoʼs whole dependency resolution works, it feels wrong to name this requirement (the way it has been done above in this thread, as far as I understand) at the same time as that – apparently mostly a suggestion? – requirement to “only apply bug fixes in patch releases” whatever exactly that's even supposed to mean.