Skip to content

Versioning Python packages: SemVer, CalVer, and PEP 440

Every Python package that lands on PyPI carries a version string. Three names show up whenever teams argue about what should be in it: PEP 440, Semantic Versioning (SemVer), and Calendar Versioning (CalVer). They describe different layers, and confusing them produces the kind of debate that burns an hour of standup and ships nothing.

PEP 440 is the grammar. It defines what a legal Python version string looks like and how releases sort. Every installer, from pip to uv to Poetry, enforces it. SemVer and CalVer are policies. They tell maintainers what the numbers should mean, not whether they’re allowed. A project can follow either policy (or neither) and still be valid PEP 440.

This page walks through each layer, shows how they interact, and gives a defensible way to pick a scheme for a new project.

PEP 440 is the grammar

PEP 440 defines the shape of a Python version string and the rules for ordering two of them. The canonical form looks like this:

[N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local]

In plain words:

  • An optional epoch prefix (1!) for projects that renumber. Almost nobody needs this.
  • A release segment: one or more dot-separated integers (1, 1.4, 1.4.2, 2024.01.15).
  • An optional pre-release suffix: a1, b2, or rc1.
  • An optional post-release suffix: .post1.
  • An optional dev-release suffix: .dev0.
  • An optional local-version tail: +cpu, +cu121, +ubi8.

PEP 440 tells the resolver exactly how these sort. 1.4.2.dev0 < 1.4.2a1 < 1.4.2b2 < 1.4.2rc1 < 1.4.2 < 1.4.2.post1 < 1.4.3 is the canonical order, confirmed against the packaging library:

$ uv run --no-project --with packaging python -c "
from packaging.version import Version
versions = ['1.4.2.dev0', '1.4.2a1', '1.4.2b2', '1.4.2rc1', '1.4.2', '1.4.2.post1']
print([str(v) for v in sorted(Version(v) for v in versions)])
"
['1.4.2.dev0', '1.4.2a1', '1.4.2b2', '1.4.2rc1', '1.4.2', '1.4.2.post1']

What PEP 440 deliberately does not prescribe: when a maintainer should bump the major number, whether a release counts as breaking, or how often to ship. That’s policy. Two projects can both be legal PEP 440 and mean completely different things by “2.0”.

For the grammar of version specifiers (==, >=, ~=, and friends) and the normalization rules around them, see the dedicated version-specifier page. The rest of this page is about the policy layer.

How SemVer maps to PEP 440

Semantic Versioning splits the release segment into three parts with contract-shaped meanings:

  • MAJOR bumps for backward-incompatible changes.
  • MINOR bumps for backward-compatible additions.
  • PATCH bumps for backward-compatible bug fixes.

Most Python libraries follow some version of this. Current releases on PyPI as of April 2026: requests 2.33.1, numpy 2.4.4, pandas 3.0.2, pytest 9.0.3, poetry 2.3.4. Pre-1.0 projects in active development (uv 0.11.7, Ruff 0.15.11) shift the contract one position left (breaking changes land on MINOR bumps) until they graduate to 1.0.

SemVer maps onto PEP 440’s release segment: MAJOR.MINOR.PATCH is three integers separated by dots, which PEP 440 accepts directly. The one mismatch is how each spec spells pre-releases.

SemVer uses hyphens and dotted identifiers: 1.0.0-alpha.1, 1.0.0-rc.1, 1.0.0-beta+exp.sha.5114f85. PEP 440’s canonical form uses no hyphen: 1.0.0a1, 1.0.0rc1, 1.0.0b0+exp.sha.5114f85. The packaging library normalizes the SemVer spellings when it reads them, but the version that ends up in metadata, filenames, and lockfiles is the PEP 440 form. If a project publishes 1.0.0-alpha.1, PyPI stores it as 1.0.0a1 and that is the string other tools see.

The practical consequence: use PEP 440 canonical form when you type the version yourself (in pyproject.toml, __version__, or a git tag). Reach for a dynamic versioning backend if you want to write SemVer-shaped Git tags (v1.0.0-rc.1) and let the build translate them.

How CalVer maps to PEP 440

Calendar Versioning encodes the release date into the version number. Common shapes:

  • YY.MINOR.PATCH (pip, Black). pip 26.0.1 means “pip’s 2026 series, minor 0, patch 1”.
  • YYYY.MM.DD (some enterprise projects). 2026.04.23 means “released on April 23, 2026”.
  • YY.MM (Ubuntu-style, used by a few Python tools). 26.04 means “April 2026”.

Several core packaging tools use CalVer: pip (currently 26.0.1), Black (26.3.1), virtualenv, PyPA’s packaging library itself. Each of those projects has a release cadence that fits the calendar better than a backward-compatibility contract does. Black, for example, publishes a breaking reformat on roughly a yearly cadence; calling that a “major” bump conveys nothing that the year doesn’t already say.

CalVer versions are legal PEP 440 out of the box because they’re just integers separated by dots. The packaging library parses them without fuss and sorts them chronologically:

$ uv run --no-project --with packaging python -c "
from packaging.version import Version
print('23.3.2 < 24.0:', Version('23.3.2') < Version('24.0'))
print('2024.01.15 sorted form:', Version('2024.01.15'))
"
23.3.2 < 24.0: True
2024.01.15 sorted form: 2024.1.15

One quirk: leading zeros in date components get stripped. 2024.01.15 becomes 2024.1.15 in metadata. The ordering is still correct because PEP 440 compares the integer values, but the canonical display drops the zero-padding. If a pipeline does string matching on versions, account for the normalization.

How ~= behaves differently under each scheme

The compatible-release operator ~= is the specifier most sensitive to scheme choice. The rule: hold every component before the last constant, let the last float upward. The consequences change completely depending on how many components the version has and what those components mean.

For a SemVer version, ~=1.4.2 pins major and minor while allowing new patch releases, exactly matching the “install compatible patches” intent:

$ uv run --no-project --with packaging python -c "
from packaging.specifiers import SpecifierSet
from packaging.version import Version
s = SpecifierSet('~=1.4.2')
print('1.4.9 matches:', Version('1.4.9') in s)
print('1.5.0 matches:', Version('1.5.0') in s)
"
1.4.9 matches: True
1.5.0 matches: False

For a CalVer project with a two-segment YY.MM scheme, ~=2024.1 holds the year and allows any release later in that year:

$ uv run --no-project --with packaging python -c "
from packaging.specifiers import SpecifierSet
from packaging.version import Version
s = SpecifierSet('~=2024.1')
print('2024.9 matches:', Version('2024.9') in s)
print('2025.0 matches:', Version('2025.0') in s)
"
2024.9 matches: True
2025.0 matches: False

For a CalVer project with a three-segment YYYY.MM.DD scheme, the same ~= operator does something surprising:

$ uv run --no-project --with packaging python -c "
from packaging.specifiers import SpecifierSet
from packaging.version import Version
s = SpecifierSet('~=2024.1.15')
print('2024.3.20 matches:', Version('2024.3.20') in s)
"
2024.3.20 matches: False

~=2024.1.15 holds year and month constant; only the day floats. That’s almost never what a downstream consumer meant to write. The lesson isn’t to avoid ~=, it’s to stop writing compatible-release specifiers without thinking about the scheme on the other side. For a calendar-dated project, >=2024.1.15,<2025 is closer to the usual intent.

The same trap catches SemVer users who paste a specifier from a Node.js project. npm’s caret (^1.4.2) looks like PEP 440’s tilde but uses different rules. ^ is not valid PEP 440. Converting to ~=1.4.2 changes behavior (caret allows minor bumps; tilde does not) unless the project uses an explicit range.

Pre-release, post-release, dev, and local segments are orthogonal

PEP 440’s pre-release, post-release, dev-release, and local-version suffixes apply regardless of whether the release segment follows SemVer or CalVer. A SemVer project can ship 2.0.0rc1 and 2.0.0.post1 the same way a CalVer project ships 2026.04.23rc1 and 2026.04.23.post1.

Each suffix has a specific meaning worth learning once:

  • Pre-release (a, b, rc): a draft of the next release, sorted before the base version. Hidden from default resolution unless the specifier opts in.
  • Post-release (.post1): a fix made after the release shipped, for metadata or packaging problems that don’t justify a new version number. Sorted after the base. Inclusive ranges like >=1.4.2 pick it up; exclusive ranges like >1.4.2 don’t.
  • Dev-release (.dev0): an intra-release snapshot for developers. Sorted before pre-releases of the same base. Almost never installed by default.
  • Local-version (+cpu, +cu121, +ubi8): an extra tag for builds that came from somewhere other than a PyPI release. PyTorch is the visible user: its CUDA wheels carry local versions like 2.1.0+cu121. PEP 440 treats the local version as compatible with the public one, so ==2.1.0 matches 2.1.0+cu121.

The version-specifier page covers the interactions between these suffixes and range operators in detail. The short version: suffixes don’t change the scheme debate. They’re orthogonal PEP 440 machinery that SemVer and CalVer projects use the same way.

Pick a scheme for your project

The honest answer is that different project shapes land in different places. A defensible default, based on what the Python ecosystem has actually converged on:

Use SemVer if:

  • The project is a library with an API surface small enough that “is this a breaking change” has an answer.
  • Downstream users need to write ~=1.4 or >=1.0,<2.0 and have it behave the way SemVer implies.

Use CalVer if:

  • The project is an application, a tool, or a service, not a library.
  • The release cadence is time-driven (monthly, quarterly, yearly), not feature-driven.
  • “Breaking change” isn’t a useful concept. Black reformats code differently every year; calling the yearly release a “major bump” doesn’t help anyone decide whether to upgrade.
  • Users care more about “how old is this” than “is it compatible.”

Use a hybrid or something custom if the project is a core packaging tool (pip, virtualenv), a long-lived platform (Ubuntu, Django), or a distribution (Anaconda). Those projects have release cadence constraints that don’t fit either scheme cleanly and have earned the right to pick their own.

For the individual developer publishing a first package, SemVer with 0.1.0 as the starting point is the safe default. For a team standardizing a fleet of internal tools where reproducibility and “what’s the latest” matter more than API contracts, CalVer pulls its weight.

Why the “just pick SemVer” advice has become contested

The case against SemVer has gotten sharper over the past few years, and teams setting versioning policy today should hear it.

Brett Cannon, a CPython core developer, wrote Why I don’t like SemVer anymore. His argument: almost every library change could break someone. If the project takes SemVer literally, every release becomes a major bump and the numbers stop conveying information. If the project hand-waves around edge-case breakages, the contract is a lie. Either outcome undermines the point.

Jacob Tomlinson, on the other side, wrote Sometimes I regret using CalVer about his experience with Dask. CalVer works great until a downstream user asks “is this release compatible with my old code?” and the version number can’t answer. SemVer at least gestures at the answer, even if imperfectly.

Both schemes are signals the user has to interpret. SemVer says “I made a commitment about compatibility.” CalVer says “I’m telling you when I shipped.” Neither is true in the absolute sense. Pick the one whose lie is most useful to the people consuming your package.

Security patches are the specific case where both schemes strain. A CVE fix that also changes observable behavior ships under SemVer as a patch release, which is technically breaking but the only option that reaches users fast. Teams writing version specifiers should plan for this regardless of which scheme the upstream uses.

Declare your policy in pyproject.toml

PEP 440 only asks that the version field parses. Everything else is convention.

For a SemVer project, the version lives in [project]:

[project]
name = "mylib"
version = "1.4.2"

For a CalVer project, the shape is identical; only the policy is different:

[project]
name = "myapp"
version = "26.4.1"

Both projects benefit from a CHANGELOG.md that explains what each bump meant, because neither scheme can carry that information in the number alone. SemVer signals scope and CalVer signals time, but users still need prose to understand what actually changed.

Teams that don’t want to edit pyproject.toml by hand can use a dynamic versioning backend to read the version from Git tags. That guide defaults to SemVer-shaped tags (v1.4.2) but the tag pattern is configurable, and a CalVer-tagging team can point it at v2026.4.1 instead. The build backend handles PEP 440 normalization regardless of which policy the tag follows.

Learn More

Last updated on

Please submit corrections and feedback...