This document exists to provide some limited data on how authors of popular Cargo, NPM, and PIP packages specify their dependencies. The goal is to get and idea of what use-cases for version constraints are common.
I describe the method at the end of the document but the TL;DR is that I looked at the 10 most "popular" packages for each of Cargo, NPM, and PIP and then categorized their their depency specifications into one of 6 patterns detailed bellow.
I find that semver compatible constraints
dominate in the Cargo and NPM ecosystem, whereas observed incompatibilities constraints
dominate in the PIP ecosystem. I explore the reasons for this in the section "PIP vs the rest" and "Technical differences matter".
I conclude that the semver compatible constraint
is the most widely used, but that it's not clear-cut if it's the best pattern to use.
This is what you'd expect it to be; a constraint of the form =1.2.3
This is by far the dominant pattern in Cargo and NPM, and the idea is very simple; Assume libraries follow semantic versioning, then, in theory, if you know that your package works with library version 1.2.3
of a library then it should work with all newer versions that are semver compatible, i.e. all versions in the range >= 1.2.3, < 2.0.0
. Usually a caret ^
is used as a short-hand for this type of constraint, so you can write ^1.2.3
(in PIP it's ~=
) instead.
A pattern of the form >=1.2.3, <3.0.0
. I.e. this is compatible with multiple major versions of a package, but there is still a semver-compat character to it.
This pattern was rare to see. It's a constraint of the form >= 1.2.3, < 1.3.0
. Assuming semantic versioning, this means that you allow new patches to you dependency, but not new additions. Usually a tilde ~
is used as a short-hand, so you can write ~1.2.3
instead.
This was the dominant pattern in packages found on PyPI. The idea is that if you package depends on library A
, then you assume that it's compatible with every version of A
until proven false. For instance, if you know that you need a feature introduced in version 1.2.3
of package A
then your dependency is of the form >= 1.2.3
. Later, in version 1.7.0
, a new feature was added that inadvertently breaks your code in an unforeseen manner, so you change your dependency constraint to >= 1.2.3, != 1.7.*
.
A constraint using advanced features of a package manager that I didn't know how to categorize.
This post details the rationale for why constraining observed incompatibilities is the dominant pattern in the PIP ecosystem, it's a good read! Some takeaways from that post that I want to highlight here:
- Semantic versioning only exists in theory. There is no 100% guarantee that a semver compatible version is actually compatible: https://xkcd.com/1172/
- A breaking change is not always a breaking change for YOUR code. If package
A
version2.0.0
removes some very niche api that you don't depend on then your library is still compatible with that version. So the constraint>= 1.2.3, <2.0.0
was actually not necessary. The result: Users of you library will end up with using an outdated version ofA
without reason. - Upper bounds can cause security issues. In the scenario above, maybe a security issue is found in
A
but the old1.2.3
version doesn't get a fix, now your package introduces a known security issue! - Semver compatible constraints can also break things. Assume a user package depends on
A
andB
.A
depends onC >=1.0.0, <2.0.0
andB
depends onC >=2.0.0, <3.0.0
. Now the user is in a pickle since the constraints cannot be solved. With PIP this is a serious pain. If we had the opposite problem however, thatA
depends onC >=1.0.0
andB
depends onC >=2.0.0
then it leaves the user with more options if there are compatibility issues, because they can add their own constraintC =2.3.1
which might actually solve the problem (maybe2.3.1
re-introduced some features that existed in1.0.0
).
For more details read https://iscinumpy.dev/post/bound-version-constraints/
There are technical differences between PIP based, and other package managers that also help in explaining why they do things differently. In PIP based package managers all dependencies live in the same namespace:
my-package
A
B
C
whereas NPM based ones have local namespaces:
my-package
A
C
B
C
Consider again the problem of A
depending on C >=1.0.0, <2.0.0
and B
depending on C >=2.0.0, <3.0.0
. The constraints are unsatisfiable, and a PIP based package manger has two options:
- Fail, and let the user try to fix it manually.
- Look for older versions of
A
andB
that might satisfy the constraints (maybe find an outdated version ofB
with dependencyC >=1.2.3, <2.0.0
)
The first one causes big headaches for the user, and the second one forces us to use outdated (an potentially insecure) dependencies.
For NPM-based package manager there's a third option
3. Install both a 1.*.*
and a 2.*.*
version of C
that A
resp. B
can depend on.
This solution probably introduces code duplication but it successfully solved the constraints.
- Semver compatible constraint is the most mainstream pattern by far.
- Semver compatible constraint is not a magic fix for every problem.
- Whether or not dependencies are nested or flat has implications for which scheme is appropriate.
I looked at the dependencies of the 10 most "popular" packages in Cargo, NPM, PyPIP and Julia, I then looked at their dependencies and categorized then according to the patterns outlined above. Of
is that I focused on what intent I thought the package authors had, not what syntax they used. For instance, >= 1.0.0, <2.0.0
, ^1.0.0
and 1.*.*
would all be categorized as a semver compatible constraint, even though they express that intent with different syntaxes.
Note1 Cargo packages can have several additional "features", and dependencies can be specified to be required for certain features only. I have categorized these features too.
2 Cargo packages can have optional dependencies, I have categorized these to.
I got the 10 most downloaded packages all-time from https://crates.io/crates?sort=downloads
exact-match | semver-compat | complex | |
---|---|---|---|
syn | 14 | 1[1] | |
bitflags | 11 | 2[2] | |
hashbrown | 13 | 2[2] | |
proc-macro2 | 6 | ||
quote | 3 | ||
libc | 1 | ||
base64 | 7 | ||
regex-syntax | 1 | ||
serde[3] | ? | ? | ? |
[1]: syn-test-suite = { version = "0", path = "tests/features" }
[2]:
core = { version = "1.0.0", optional = true, package = "rustc-std-workspace-core" }
alloc = { version = "1.0.0", optional = true, package = "rustc-std-workspace-alloc" }
[3]: I didn't understand how to interpret it's Cargo.toml
.
I got the 10 most depended upon packages all-time from https://gist.github.com/anvaka/8e8fa57c7ee1350e3491
exact-match | semver-compat | approx-match | |
---|---|---|---|
lodash v4.17.21 | 3 | 20 | 4 |
chalk v5.4.1 | 10 | ||
request v2.88.2 | 25 | 15 | |
commander v13.1.0 | 13 | ||
react v19.0.0 | 3 | 99 | |
express v4.21.2 | 8 | 34 | 1 |
debug v4.4.0 | 13 | ||
async v3.2.6 | 1 | 36 | |
fs-extra v11.3.0 | 11 | ||
moment v2.30.1 [1] | 1 | 4 |
[1] moment
has many dev-dependencies of the form "name": "latest"
which have not been counted.
Note I accedentally misscategorized Wide semver compatible constraint
as semver compatible constraint
. However, there where approximately 4 constraints of this form that I found here, so it won't tip any scales.
I got the 10 most downloaded packages in the past month from https://pypistats.org/top
exact-match | semver-compat | approx-match | observed-incompat | complex | |
---|---|---|---|---|---|
boto3 | 4 | 2 | 3[4] | ||
urllib3 | 1 | 40 | 2[2] | ||
botocore | 4 | 2 | 3[4] | ||
requests | 1 | 2[4] | 3 | ||
certifi | 26 | ||||
charset-normalizer | 1 | 1[5] | |||
setuptools | 1 | 1 | 1 | 14 | 9[6] |
idna | 4 | ||||
grpcio-status | 1 | 2[7] | |||
typing-extensions | 1 |
[2]:
"brotli>=1.0.9; platform_python_implementation == 'CPython'", "brotlicffi>=0.8.0; platform_python_implementation != 'CPython'"
"pytest-pyodide>=0.58.4 ; python_full_version >= '3.10'"
[3]: Not sure if these are wildcard, it's just a package name without any version specifier, like: "hypercorn"
.
[4]: An example of two syntaxes for the same thing intent: We have pytest>=2.8.0,<9
, semver-compat. We have also httpbin~=0.10.0
which is equivalent to >=0.10.0, ==0.10.*
which in turn is equivalent to >=0.10.0, <0.11.0
source.
[5]:
coverage>=7.2.7,<7.7
[6]:
'pytest-perf; sys_platform != "cygwin"', # workaround for jaraco/inflect#195, pydantic/pydantic-core#773 (see #3986)
'jaraco.develop >= 7.21; python_version >= "3.9" and sys_platform != "cygwin"',
"pyproject-hooks!=1.1" # workaround for pypa/pyproject-hooks#206
"pyproject-hooks!=1.1" # workaround for pypa/setuptools#4333
"towncrier<24.7" # workaround for sphinx-contrib/sphinxcontrib-towncrier#92
"importlib_metadata>=6; python_version < '3.10'"
"tomli>=2.0.1; python_version < '3.11'"
"pytest-ruff >= 0.2.1; sys_platform != 'cygwin'"
"ruff >= 0.8.0; sys_platform != 'cygwin'" # Removal of deprecated UP027, PT004 & PT005 astral-sh/ruff#14383
"importlib_metadata>=7.0.2; python_version < '3.10'"
'jaraco.develop >= 7.21; sys_platform != "cygwin"'
[7]:
"protobuf>=5.26.1,<6.0dev"
"grpcio>={version}".format(version=grpc_version.VERSION)