Skip to main content

Semantic Versioning - The Contract Behind Every Version Number

Reading time: ~25 minutes | Level: Intermediate → Engineering

Before reading further, study this scenario:

# Your service depends on: mylib>=1.2.0
# Your code in production:
from mylib import Client
client = Client(timeout=30)

# ─── mylib releases 1.3.0 ───────────────────────────────────────────────────
# Release notes: "Added AsyncClient for async frameworks"
# Breaking changes: None
# Your code: still works, Client(timeout=30) unchanged

# ─── mylib releases 2.0.0 ───────────────────────────────────────────────────
# Release notes: "Modernized API - Client renamed to SyncClient for clarity"
# Your code after upgrade:
from mylib import Client
# AttributeError: module 'mylib' has no attribute 'Client'

Which release broke your code? 2.0.0. And if the library authors followed Semantic Versioning correctly - that is the point. A MAJOR version bump is an explicit signal: breaking changes are intentional and expected. The version number is not metadata. It is a contract.

The problem is that many library authors do not follow SemVer correctly. A 1.3.0 release with a breaking change, or a 2.0.0 release with no breaking changes - both destroy user trust and make version constraints unreliable. Understanding SemVer in depth means understanding both what it requires and how to defend yourself when dependencies do not follow it.

What You Will Learn

  • The formal MAJOR.MINOR.PATCH definitions and exactly what each increment means
  • How to classify changes: what is breaking, what is not, and the gray areas
  • Pre-release and build metadata versioning
  • The 0.x.y exception: the wild west before 1.0.0
  • Python version specifiers and how they map to SemVer concepts
  • Using packaging.version.Version to parse and compare versions in code
  • CalVer as an alternative versioning scheme
  • Changelog discipline and Git release tagging

Prerequisites

  • Lesson 04 (Poetry) - version constraints in Poetry directly implement SemVer ranges
  • Basic understanding of package dependencies - knowing what it means to depend on a library version

Part 1 - The MAJOR.MINOR.PATCH Contract

Semantic Versioning (SemVer, semver.org) defines exactly three rules:

PATCH (1.2.31.2.4): Backwards-compatible bug fixes. No new public API. No changed behavior that existing users depended on. Only fixes for behavior that was unambiguously wrong.

MINOR (1.2.31.3.0): New backwards-compatible functionality. New public API added. Existing API unchanged. Users who do not use the new API are unaffected.

MAJOR (1.2.32.0.0): Backwards-incompatible changes. Any change that breaks existing users. This is the explicit signal that upgrading may require code changes.

The engineering implications:

If you depend on: mylib>=1.0.0
You are saying: "Any 1.x.y version will work for me."

If the library follows SemVer:
1.0.0 → 1.0.1: safe (bug fix)
1.0.0 → 1.1.0: safe (new features, your old code still works)
1.0.0 → 2.0.0: NOT automatically safe (breaking changes expected)

Part 2 - Which Number to Bump: The Decision Tree

The decision is always about the public API contract, not about internal complexity or how much code changed.

Part 3 - What Counts as a Breaking Change

This is where engineering judgment matters. The SemVer spec requires MAJOR bumps for any backwards-incompatible change - but "backwards-incompatible" requires interpretation.

Unambiguous Breaking Changes (Always MAJOR)

# ── Removing a public name ────────────────────────────────────────────────────
# Version 1.x
from mylib import Client, AsyncClient, parse_url

# Version 2.0 - Client removed
from mylib import AsyncClient, parse_url
# → AttributeError for any user of Client

# ── Changing a function signature ────────────────────────────────────────────
# Version 1.x
def connect(host, port=5432, timeout=30):
...

# Version 2.0 - positional argument reordered
def connect(host, timeout=30, port=5432):
...
# → Any caller using connect(host, 5432) now passes port as timeout

# ── Changing a return type ────────────────────────────────────────────────────
# Version 1.x: returns dict
def get_config() -> dict:
return {"host": "localhost", "port": 5432}

# Version 2.0: returns Config object
def get_config() -> Config:
return Config(host="localhost", port=5432)
# → Any caller doing config["host"] will get TypeError

# ── Changing exception type ───────────────────────────────────────────────────
# Version 1.x
raise ConnectionError("timeout")

# Version 2.0
raise TimeoutError("timeout")
# → Any caller catching ConnectionError now misses the exception

# ── Narrowing accepted input ──────────────────────────────────────────────────
# Version 1.x: accepts str or int
def set_timeout(value: Union[str, int]) -> None: ...

# Version 2.0: accepts only int
def set_timeout(value: int) -> None: ...
# → Users passing str now get TypeError

What Does NOT Count as Breaking (Can be MINOR or PATCH)

# ── Adding a new optional parameter ──────────────────────────────────────────
# Version 1.x
def connect(host, port=5432):
...

# Version 1.5.0 - new optional parameter added at the end
def connect(host, port=5432, ssl=False):
...
# → All existing callers continue to work unchanged

# ── Adding a new public function or class ─────────────────────────────────────
# Version 1.5.0 - new AsyncClient added
# Existing Client users unaffected

# ── Improving performance ────────────────────────────────────────────────────
# Faster algorithm that produces the same result: PATCH (or MINOR if significant)

# ── Fixing a bug (unambiguously wrong behavior) ──────────────────────────────
# parse_date("2024-01-32") returned a date instead of raising ValueError: PATCH

# ── Adding type annotations ──────────────────────────────────────────────────
# Adding Py.typed marker and annotations to a previously untyped library: MINOR
# (technically cannot break runtime, but may affect mypy users - gray area)

# ── Updating documentation ───────────────────────────────────────────────────
# No version bump required (but often bundled with PATCH releases)

The Gray Areas

# ── Deprecating an API ────────────────────────────────────────────────────────
# Deprecation warnings: MINOR (not breaking - code still runs)
# Removing the deprecated API: MAJOR (breaking)

import warnings
def old_function():
warnings.warn("old_function is deprecated, use new_function", DeprecationWarning, stacklevel=2)
return new_function()

# ── Changing default values ──────────────────────────────────────────────────
# Version 1.x: default timeout=30
# Version 1.5.0: default timeout=60
# This changes behavior for users who relied on the default - technically breaking
# But many projects call this MINOR and document it prominently

# ── Removing a dependency ────────────────────────────────────────────────────
# If users import from your library's re-exported dependencies:
# from mylib import requests ← users doing this break when you stop re-exporting
# This is a breaking change even though it looks internal
danger

Releasing a breaking change as a MINOR version destroys user trust permanently. If your library's 1.3.0 release breaks users' code, they will either pin to ==1.2.3 forever (preventing them from getting your future security fixes) or switch to a competing library. Breaking SemVer is not a technical mistake - it is an organizational credibility failure. The community's ability to use ^1.2.3 constraints safely depends on library authors following MAJOR version rules faithfully.

Part 4 - Pre-Release Versions

SemVer supports pre-release identifiers appended with a hyphen:

1.0.0-alpha.1 ← first alpha
1.0.0-alpha.2 ← second alpha
1.0.0-beta.1 ← first beta
1.0.0-beta.2 ← second beta
1.0.0-rc.1 ← first release candidate
1.0.0-rc.2 ← second release candidate
1.0.0 ← stable release

Precedence ordering (lower to higher):

1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0

A stable release always has higher precedence than any pre-release with the same MAJOR.MINOR.PATCH. 1.0.0-rc.9 is still lower than 1.0.0.

In Python/PyPI, the convention uses . instead of -:

1.0.0a1 ← alpha 1 (PEP 440)
1.0.0b2 ← beta 2
1.0.0rc1 ← release candidate 1
1.0.0 ← stable

Pre-release versions are not installed by default:

pip install mylib # installs latest stable
pip install mylib==2.0.0a1 # explicitly request alpha
pip install mylib --pre # allow pre-releases

Build Metadata

SemVer also supports build metadata appended with +:

1.0.0+20240315 ← built on 2024-03-15
1.0.0+sha.a1b2c3d ← built from specific git SHA
1.0.0-beta.1+exp.sha.5114f85

Build metadata is ignored in version precedence comparisons. 1.0.0+build.1 and 1.0.0+build.2 are considered equal versions.

Part 5 - The 0.x.y Exception

SemVer has an important rule for versions before 1.0.0:

Major version zero (0.y.z) is for initial development. Anything may change at any time. The public API should not be considered stable.

This is the "wild west" rule. Under 0.x.y:

  • MINOR increments may include breaking changes
  • There is no guarantee that 0.2.0 is backwards-compatible with 0.1.0
  • The MAJOR version bump to 1.0.0 is the commitment: "the API is now stable"
tip

Stay at 0.x.y until your API is genuinely stable. Do not rush to 1.0.0 just because the library "feels done." Once you release 1.0.0, you are committing to backwards compatibility in all future 1.x.y versions. Many successful libraries (requests, click) spent years under 0.x.y while they refined their APIs. Bumping to 1.0.0 is a public commitment, not a milestone celebration.

Practical 0.x.y versioning for a library in development:

0.1.0 ← first functional release
0.1.1 ← bug fix
0.2.0 ← new feature + breaking change to alpha API (allowed before 1.0.0)
0.3.0 ← API stabilizing...
1.0.0 ← public commitment: this API is stable

Part 6 - Python Version Specifiers and How They Map to SemVer

Python's packaging ecosystem (PEP 440) defines its own version specifier syntax. Understanding how it relates to SemVer constraints prevents version constraint bugs.

The >=,< Explicit Range

>=1.2.0,<2.0.0 ← most explicit; exactly matches SemVer "^1.2.0" intent
>=2.0,<3.0 ← commonly used for major version pinning
>=1.2.0 ← open-ended: works until something breaks (avoid in libraries)

The Compatible Release Operator ~=

The ~= operator (PEP 440) is more subtle than it appears:

~=2.2.0 ← requires >=2.2.0 AND == 2.2.* → allows 2.2.0, 2.2.1, 2.2.9
(three components: pins to 2.2.x patch releases only)

~=2.2 ← requires >=2.2 AND == 2.* → allows 2.2, 2.3, 2.9
(two components: pins to 2.x minor + patch releases)

~=1.4.2 ← requires >=1.4.2 AND == 1.4.* → allows 1.4.2, 1.4.3, NOT 1.5.0
note

The number of version components in ~= changes its meaning entirely. ~=2.2 (two components) allows 2.3, 2.9, etc. - equivalent to Poetry's ^2.2 but only for the minor series. ~=2.2.0 (three components) only allows 2.2.x - equivalent to Poetry's ~2.2.0. Always count the dots when reading ~= constraints in requirements.txt or setup.cfg.

Poetry vs PEP 440 Constraint Mapping

PoetryPEP 440 equivalentWhat it allows
^1.2.3>=1.2.3,<2.0.0minor + patch updates
^1.2>=1.2.0,<2.0.0minor + patch updates
~1.2.3>=1.2.3,<1.3.0patch updates only
~1.2>=1.2.0,<1.3.0patch updates only
>=1.2,<2.0>=1.2,<2.0explicit range
==1.2.3==1.2.3exact pin
warning

Many Python packages do not follow SemVer strictly. boto3, botocore, google-cloud-*, and many AWS/GCP libraries use MINOR versions for breaking changes and release multiple times per week. numpy used MAJOR versions sparingly for years while making breaking changes in MINOR versions. Always read the release notes of your direct dependencies before running poetry update package. The version number is a signal, not a guarantee.

Part 7 - Using packaging.version.Version in Code

Sometimes you need to compare or parse versions programmatically. The packaging library (always available in environments with pip) provides PEP 440-compliant version parsing:

from packaging.version import Version, InvalidVersion
from packaging.specifiers import SpecifierSet

# Parse and compare versions
v1 = Version("1.2.3")
v2 = Version("1.10.0")
v3 = Version("2.0.0a1") # alpha

print(v1 < v2) # True (1.2.3 < 1.10.0 - note: NOT lexicographic comparison)
print(v2 < v3) # True (1.10.0 < 2.0.0a1)
print(v3.is_prerelease) # True
print(v3.major, v3.minor, v3.micro) # 2, 0, 0

# Check if a version satisfies a specifier
spec = SpecifierSet(">=1.2.0,<2.0.0")
print(Version("1.5.0") in spec) # True
print(Version("2.0.0") in spec) # False
print(Version("1.0.0") in spec) # False

# Handle invalid versions gracefully
def parse_version_safe(version_str: str) -> Version | None:
try:
return Version(version_str)
except InvalidVersion:
return None

# Sort a list of versions correctly
versions = [Version(v) for v in ["1.10.0", "1.2.0", "1.9.0", "2.0.0a1"]]
print(sorted(versions))
# [<Version('1.2.0')>, <Version('1.9.0')>, <Version('1.10.0')>, <Version('2.0.0a1')>]
# Note: 1.9.0 < 1.10.0 (numeric, not lexicographic - "10" > "9")

A common real-world use: a service that checks whether its own version is newer than the latest released version on PyPI:

import httpx
from packaging.version import Version

def get_latest_pypi_version(package: str) -> Version:
response = httpx.get(f"https://pypi.org/pypi/{package}/json", timeout=5)
response.raise_for_status()
data = response.json()
return Version(data["info"]["version"])

def check_for_updates(package: str, current_version: str) -> None:
current = Version(current_version)
latest = get_latest_pypi_version(package)

if latest > current:
print(f"Update available: {current}{latest}")
elif latest < current:
print(f"Running pre-release or unreleased version: {current}")
else:
print(f"Up to date: {current}")

Part 8 - CalVer: Calendar Versioning

Not every project suits SemVer. Some projects use CalVer (Calendar Versioning): the version number encodes the release date rather than semantic information.

Common CalVer formats:

ProjectFormatExampleWhat the segments mean
UbuntuYY.0M24.04Year 2024, April (the 0M pads to 2 digits)
DjangoMAJOR.MINOR5.2Not CalVer - Django uses custom SemVer-like scheme
PythonMAJOR.MINOR.PATCH3.12.3MAJOR=Python generation, MINOR=annual feature release
pipYY.N24.1Year + release number within year
BlackYY.M.N24.3.0Year, month, release number

When CalVer makes sense:

  1. Infrastructure tools that release on a schedule (Ubuntu LTS: every 2 years, non-LTS: every 6 months). The date IS the relevant information for support lifecycle decisions.
  2. Tools where "semantic" API stability is not the primary concern - a text formatter like black has one job; the version encoding its release date tells you how current it is.
  3. Projects with a regular release cadence where the date provides useful information to users.

When CalVer does NOT make sense:

  • Libraries with a public API - users need to know whether upgrading is safe (SemVer's MAJOR signal)
  • Libraries with many downstream dependents - CalVer gives no compatibility signal
  • Any project where users need version constraints to protect against breaking changes

For the vast majority of Python libraries and packages, SemVer is the right choice.

Part 9 - Changelog Discipline

A version number without a changelog is incomplete communication. The Keep a Changelog format is the most widely adopted standard:

# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Draft support for async context managers in `Client`

## [2.0.0] - 2024-06-15

### Breaking Changes
- `Client` renamed to `SyncClient` - update all imports
- `parse_url()` now raises `URLParseError` instead of `ValueError`
- Dropped support for Python 3.8 and 3.9

### Added
- `AsyncClient` for use with `asyncio` and `httpx`
- `URLParseError` exception with detailed parse information

### Removed
- `Client` class (was `SyncClient` alias since 1.4.0)
- `parse_url_legacy()` deprecated in 1.3.0

## [1.5.0] - 2024-03-01

### Added
- `Client.retry(max_attempts=3)` method for automatic retry logic
- Optional `ssl_verify` parameter to `Client.__init__`

### Fixed
- `Client.get()` no longer hangs on connection timeout; now raises `TimeoutError`
- Improved error messages for invalid host strings

## [1.4.3] - 2024-01-20

### Fixed
- Fixed memory leak when using `Client` as a context manager in long-running processes

[Unreleased]: https://github.com/org/mylib/compare/v2.0.0...HEAD
[2.0.0]: https://github.com/org/mylib/compare/v1.5.0...v2.0.0
[1.5.0]: https://github.com/org/mylib/compare/v1.4.3...v1.5.0
[1.4.3]: https://github.com/org/mylib/releases/tag/v1.4.3

The Keep a Changelog categories:

CategoryUse for
AddedNew features, new public API
ChangedChanges to existing behavior (non-breaking or documented behavioral changes)
DeprecatedFeatures marked for future removal
RemovedFeatures removed (always MAJOR if public API)
FixedBug fixes
SecuritySecurity vulnerability fixes
Breaking Changes(not in spec, but widely used for MAJOR versions) - list explicitly

Part 10 - Git Tagging for Releases

Releases must be tagged in Git. Tags create permanent, named references to specific commits. The standard convention is v prefix:

# Create a lightweight tag (just a pointer to a commit)
git tag v1.2.3

# Create an annotated tag (includes tagger, date, message - preferred for releases)
git tag -a v1.2.3 -m "Release 1.2.3: fix memory leak in Client context manager"

# List all tags
git tag -l
git tag -l "v1.*" # filter

# Push a tag to remote (tags are not pushed by default)
git push origin v1.2.3

# Push all tags
git push origin --tags

# Show what a tag points to
git show v1.2.3

Annotated vs Lightweight Tags

FeatureLightweight tagAnnotated tag
Stored asSimple pointer to commitFull Git object with metadata
Includes tagger + dateNoYes
Includes messageNoYes
Used by git describeYesYes (preferred)
Best forTemporary/personal useReleases - always use annotated

The Full Release Workflow

# 1. Finalize changelog
# Move [Unreleased] section to [1.2.3] - YYYY-MM-DD

# 2. Bump version in pyproject.toml
poetry version patch # 1.2.2 → 1.2.3
poetry version minor # 1.2.2 → 1.3.0
poetry version major # 1.2.2 → 2.0.0
# Or set explicitly:
poetry version 1.2.3

# 3. Commit the version bump
git add pyproject.toml CHANGELOG.md
git commit -m "chore: release 1.2.3"

# 4. Create annotated tag
git tag -a v1.2.3 -m "Release 1.2.3"

# 5. Push commit and tag
git push origin main
git push origin v1.2.3

# 6. Build and publish (see Lesson 06)
poetry build
poetry publish

CI pipelines (GitLab CI, GitHub Actions) can trigger automated publish jobs on tags matching v*, making steps 6 entirely automated.

Graded Practice

Level 1 - Classify the Change

For each change below, determine whether it requires a PATCH, MINOR, or MAJOR version bump:

  1. A bug where parse_date("2024-02-29") returned None is fixed to return the correct datetime object.
  2. A new optional parameter encoding="utf-8" is added to read_file(path). Default behavior unchanged.
  3. read_file(path) previously returned str; now returns bytes by default.
  4. Internal refactoring: the _parse_internal() private method is rewritten to be 3x faster.
  5. APIClient.connect() now raises ConnectionRefusedError instead of returning False on failure.
  6. A new AsyncAPIClient class is added alongside the existing APIClient.
  7. APIClient.__init__ now requires timeout as a keyword-only argument (timeout=30*, timeout=30).
  8. Docstrings are added to all public functions.
Show Answer
  1. PATCH - Bug fix. The previous behavior was wrong (returning None for a valid date); fixing it is unambiguously a patch release. No API change.

  2. MINOR - New optional parameter with a default value that preserves existing behavior. All existing callers continue to work without modification. This adds new capability (callers can now pass encoding) without breaking anything.

  3. MAJOR - Changing a return type from str to bytes is a breaking change. Any user who does content.upper() or content.replace("old", "new") will get AttributeError at runtime (bytes has different methods than str). Even if the change seems logical, it breaks existing code.

  4. PATCH - Private methods (prefixed with _) are not part of the public API contract. Internal refactoring that changes no observable behavior is a patch release. Performance improvements with identical results are patch-level changes.

  5. MAJOR - Changing how failure is signaled is a breaking change. Users doing if not client.connect(): handle_failure() now get an unhandled exception instead. Even if raising is the "better" behavior, changing from return-based to exception-based failure handling breaks all existing callers.

  6. MINOR - Adding a new class (AsyncAPIClient) is purely additive. No existing code is affected. Users who do not need async can ignore the new class entirely.

  7. MAJOR - Making timeout keyword-only means any caller using APIClient(host, port, 30) (passing timeout positionally as the third argument) now gets TypeError: connect() takes 2 positional arguments but 3 were given. This is a breaking change even though most callers who used timeout=30 explicitly are unaffected.

  8. PATCH (or no version bump) - Docstrings are not part of the runtime API. This is a documentation change. Many projects bundle documentation improvements into PATCH releases; some do it without a version bump. Either is acceptable.

Level 2 - Debug the Version Strategy

A library data-tools has this version history:

0.8.0 → 0.9.0: parse_csv() function added
0.9.0 → 0.9.1: bug fix in parse_csv()
0.9.1 → 1.0.0: API "stabilized", no changes
1.0.0 → 1.1.0: parse_json() added, parse_csv() signature changed (breaking)
1.1.0 → 1.1.1: performance improvement
1.1.1 → 1.2.0: parse_xml() added, parse_json() removed (breaking)
1.2.0 → 2.0.0: internal refactor, no public API changes

Identify every SemVer violation and explain the correct version increment for each.

Show Answer

Violation 1: 1.0.0 → 1.1.0 - breaking change released as MINOR

parse_csv() signature changed - this is backwards-incompatible. Under SemVer, this requires a MAJOR bump. Correct increment: 1.0.0 → 2.0.0.

This is the most damaging violation. Anyone using >=1.0.0 or ^1.0.0 constraints will automatically receive this "upgrade" and have their code broken. The caret constraint exists precisely to protect against this.

Violation 2: 1.1.0 → 1.2.0 - breaking change (removal) released as MINOR

parse_json() was removed. Removing public API is always a MAJOR bump. Correct increment: 1.1.0 → 2.0.0 (or 2.0.0 → 3.0.0 if the previous violation was correctly bumped to MAJOR).

Violation 3: 1.2.0 → 2.0.0 - no breaking changes, but MAJOR bumped

The MAJOR version was incremented for an internal refactor with no public API changes. This is not a SemVer violation per se (the spec does not prohibit MAJOR bumps without breaking changes - it only says MAJOR MUST be incremented for breaking changes). But it is misleading: users of ^1.x.x constraints now need to explicitly upgrade and review what changed, even though nothing they use changed. Correct increment: 1.2.0 → 1.2.1 (if the refactor was internal) or 1.3.0 (if it added any new capability).

Assessment:

A user who started with data-tools ^1.0.0 (meaning "safe to use any 1.x.y") was broken twice before reaching 1.2.0. This library destroyed its users' trust. The typical response is that users pin to ==1.0.0 and refuse to upgrade - which means they never receive future security patches or bug fixes either. The cost of SemVer violations is paid by users, indefinitely.

Level 3 - Design the Versioning Strategy

You are the maintainer of a Python library httpkit that has been in production use for 2 years. Current version: 1.8.3. It has 50+ known users (from GitHub stars and PyPI download stats). You have the following planned changes for the next 6 months:

  1. Fix a bug where Client.get() does not properly close connections on timeout (fix is backwards-compatible)
  2. Add AsyncClient class (new, additive)
  3. Deprecate Client.set_proxy(url) in favor of Client(proxies={"https": url})
  4. Remove Client.set_proxy() (it has been deprecated for 2 releases)
  5. Rename Client.cookies property to Client.cookie_jar for clarity
  6. Improve JSON parsing performance by 40% (internal change)
  7. Add support for Python 3.13 (test and CI only)

Design the complete release plan:

  • Which changes go into which release?
  • What are the version numbers?
  • What is in the CHANGELOG.md for each release?
  • What constraints do you add to warn users?
  • How do you communicate breaking changes to minimize disruption?
Show Answer

Release plan:

1.8.4 ← bug fix only
1.9.0 ← additive: AsyncClient + deprecation
2.0.0 ← breaking: removal + rename

Release 1.8.4: Contains: Change 1 (bug fix) + Change 7 (Python 3.13 support - additive, non-breaking).

## [1.8.4] - 2024-07-01

### Fixed
- `Client.get()` now properly closes connections on timeout, preventing connection pool exhaustion

### Added
- Python 3.13 support (tested and verified in CI)

Release 1.9.0: Contains: Change 2 (AsyncClient) + Change 3 (deprecation) + Change 6 (performance).

## [1.9.0] - 2024-09-15

### Added
- `AsyncClient` class for use with asyncio; see docs for migration guide
- 40% performance improvement in JSON response parsing (internal change)

### Deprecated
- `Client.set_proxy(url)` is deprecated and will be removed in 2.0.0.
Use `Client(proxies={"https": url})` instead.
A `DeprecationWarning` is now raised when `set_proxy()` is called.

Implementation of the deprecation:

def set_proxy(self, url: str) -> None:
import warnings
warnings.warn(
"Client.set_proxy() is deprecated and will be removed in 2.0.0. "
"Use Client(proxies={'https': url}) instead.",
DeprecationWarning,
stacklevel=2,
)
self._proxies = {"https": url}

Release 2.0.0: Contains: Change 4 (removal of set_proxy) + Change 5 (rename cookiescookie_jar).

## [2.0.0] - 2025-01-10

### Breaking Changes

**Migration required - read before upgrading**

1. `Client.set_proxy(url)` has been removed (deprecated since 1.9.0).
Migration: `Client(proxies={"https": url})`

2. `Client.cookies` property renamed to `Client.cookie_jar`.
Migration: replace all uses of `.cookies` with `.cookie_jar`.
A compatibility shim is available for the 2.x lifecycle:
`Client.cookies` raises `AttributeError` with a helpful message pointing to `.cookie_jar`.

### Migration Guide

See https://httpkit.readthedocs.io/2.0/migration for a complete migration guide
with automated codemods using `libcst`.

### Removed
- `Client.set_proxy()` (deprecated in 1.9.0)

### Changed
- `Client.cookies``Client.cookie_jar` (breaking rename)

Communication strategy:

  1. Release 1.9.0 with DeprecationWarning 3+ months before 2.0.0. Users who run python -W error::DeprecationWarning in CI (which they should) get failing tests, prompting them to migrate.

  2. Provide a migration guide document and ideally a codemod script using libcst that automatically renames client.cookiesclient.cookie_jar and rewrites client.set_proxy(url)Client(proxies=...).

  3. In 2.0.0, implement Client.cookies as a property that raises AttributeError with a descriptive message: AttributeError: 'cookies' was renamed to 'cookie_jar' in httpkit 2.0.0. Update: client.cookies → client.cookie_jar. This gives users a clear error at the right call site instead of a confusing AttributeError or wrong behavior.

  4. Announce 2.0.0 release on the project's communication channels (GitHub Discussions, mailing list if applicable) 2 weeks before release with the migration guide.

Key Takeaways

  • MAJOR.MINOR.PATCH is a contract, not a label. MAJOR means breaking changes expected; MINOR means new features, nothing broken; PATCH means bug fixes only. All three commitments must be honored for the version system to be trustworthy.
  • A breaking change is any change that can cause existing correct user code to fail. This includes: removing public names, changing signatures, changing return types, changing exception types, and narrowing accepted input types.
  • Adding new optional parameters, new classes, new functions, or fixing bugs is not breaking - these are MINOR or PATCH changes respectively.
  • Pre-release versions (1.0.0a1, 1.0.0b2, 1.0.0rc1) always have lower precedence than the stable release. pip install mylib ignores pre-releases; use --pre or explicit pins to install them.
  • 0.x.y is the wild west: the spec explicitly allows breaking changes in MINOR versions before 1.0.0. Stay at 0.x.y until your API is stable; bumping to 1.0.0 is a public commitment.
  • ~=2.2.0 (three components) and ~=2.2 (two components) mean different things: three components restricts to 2.2.x patches; two components allows all 2.x.y versions. Count the dots.
  • Many Python packages do not follow SemVer strictly - especially AWS SDKs, Google Cloud libraries, and large frameworks. Always read release notes before running poetry update or pip install --upgrade.
  • Use packaging.version.Version for all programmatic version parsing and comparison. Never parse version strings with string operations or regex - "1.10.0" > "1.9.0" is False lexicographically but True semantically.
  • Changelog discipline is inseparable from versioning: a MAJOR release without a migration guide is an incomplete release. Use the Keep a Changelog format; document breaking changes explicitly in a Breaking Changes section.
  • Releasing a breaking change as a MINOR version destroys user trust permanently. Users respond by pinning to the last good version with == constraints, which prevents them from receiving all future security fixes. The cost of a SemVer violation is paid by users indefinitely.
© 2026 EngineersOfAI. All rights reserved.