Skip to content

Instantly share code, notes, and snippets.

@emmabastas
Last active March 4, 2025 20:01
Show Gist options
  • Save emmabastas/dabeb030520a470d85945b845dfba920 to your computer and use it in GitHub Desktop.
Save emmabastas/dabeb030520a470d85945b845dfba920 to your computer and use it in GitHub Desktop.

Comparison of version constraint patterns in Cargo, NPM, and PIP

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.

Patterns

Pattern: Exact matches

This is what you'd expect it to be; a constraint of the form =1.2.3

Pattern: Semver compatible constraint

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.

Pattern: Wide semver compatible constraint

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.

Pattern: Approximately the same

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.

Pattern: Observed incompatibilities constraint

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.*.

Pattern: Complex

A constraint using advanced features of a package manager that I didn't know how to categorize.

PIP vs the rest

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 version 2.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 of A without reason.
  • Upper bounds can cause security issues. In the scenario above, maybe a security issue is found in A but the old 1.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 and B. A depends on C >=1.0.0, <2.0.0 and B depends on C >=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, that A depends on C >=1.0.0 and B depends on C >=2.0.0 then it leaves the user with more options if there are compatibility issues, because they can add their own constraint C =2.3.1 which might actually solve the problem (maybe 2.3.1 re-introduced some features that existed in 1.0.0).

Technical differences matter

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:

  1. Fail, and let the user try to fix it manually.
  2. Look for older versions of A and B that might satisfy the constraints (maybe find an outdated version of B with dependency C >=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.

Conclusions

  • 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.

Method

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.

Cargo

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.

NPM

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.

PIP

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)

TODO Julia

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment