Ask a room of engineers what a breaking change is and you will get: “a change to a function signature.” That definition is wrong in a specific, expensive way — it is a definition of a type change, and most breakages are not type changes.
Here are four releases that break users without touching a single signature:
# 1. The exception type changed.
def fetch(url: str) -> bytes: ...
# was: raises ConnectionError
# now: raises FetchError
# Every caller's `except ConnectionError` is now a crash.
# 2. The iteration order changed.
def tags(self) -> list[str]: ...
# was: insertion order
# now: sorted
# Someone's golden-file test fails; someone's UI reorders; someone's
# "first tag is the primary one" heuristic silently returns a different tag.
# 3. A default argument changed.
def render(text: str, *, escape: bool = False) -> str: ...
# now: escape: bool = True
# Strictly safer! And every caller relying on the old default now
# double-escapes, which is a visible corruption in production.
# 4. A returned object gained a field.
# Harmless — unless a caller round-trips it through a strict schema,
# or compares it with ==, or hashes it.
The working definition:
A breaking change is any change that makes a program that used to work stop working, or start producing different output.
Not “a change to the declared interface”. The declared interface is one of the things users depend on, and it is not even the largest one.
💡You maintain a library whose parse() currently raises ValueError on bad input. You want a richer ParseError carrying position information. How do you ship that without breaking anyone?
click to reveal
Make ParseError inherit from ValueError.
class ParseError(ValueError):
def __init__(self, message: str, *, line: int, column: int) -> None: ...
Every existing except ValueError keeps catching it, so no caller breaks. New
callers can except ParseError and read .line. You have widened the
information without narrowing the contract.
This is the general technique and it generalises past exceptions: new capability arrives as a subtype, a new keyword argument with the old default, or a new function — never as a redefinition of an existing name. An exception hierarchy designed this way (a package-level base, then specific subclasses) is the single highest-leverage API decision most libraries never make, because it turns “we changed the exception type” from a major-version event into a patch.
The one thing to check before you ship it: if your new class also inherits from
something else — say ParseError(ValueError, LookupError) — you have changed
what a caller’s except LookupError catches too. Widening what an exception
is is as visible as narrowing it.
Declaring the surface so the machine can check it
You cannot avoid breaking a contract you never wrote down. Three artefacts turn “public API” from a wiki page into something the tooling enforces:
__all__, in every public module. Without it, every non-underscore name you
happen to have imported is part of your public API. from .db import session in
an __init__.py has just published session; from yourpkg import * will hand
it out; and a user who imports it is now a compatibility obligation you never
agreed to.
--no-implicit-reexport (part of --strict) makes the type checker agree
with you: an imported name is private unless re-exported deliberately, spelled
from .x import Y as Y or listed in __all__. Note the split, because it
surprises people: mypy does not validate that the contents of __all__
actually exist or are exported — pyright does. If __all__ accuracy matters to
you (it should), that check has to come from pyright, a lint rule, or a test.
py.typed. A zero-byte marker file in the package directory, and — the part
that gets forgotten — in the built wheel. Without it, PEP 561 says a
consumer’s type checker must ignore your inline annotations entirely, and every
value crossing your API surface becomes Any in their code. You will not notice,
because your own mypy --strict run reads your source directly.
Versioning behaviour, not signatures
Semantic versioning is usually explained in terms of the API. Apply it to behaviour:
- Patch — a bug fix that nobody could reasonably have depended on. Be honest about “reasonably”: if the bug has been there for three years, someone has worked around it.
- Minor — new capability, old programs unchanged. New function, new optional keyword argument, new exception subclass, new field.
- Major — anything from the list at the top of this page.
Two rules that make this workable in practice. First, write down what is not covered: the repr of your objects, the exact wording of messages, the order of a set, the contents of a traceback. If you do not say those are unstable, someone will pin a test to them and you will have made them stable by accident. Second, treat “it was undocumented” as an argument you will lose. Hyrum’s Law is an observation, not a rule you can appeal.
The deprecation cycle
Announce, keep working, remove — and each step has a required ingredient.
Announce. Say the version it goes away in and the thing to use instead. A deprecation notice without a replacement is a complaint.
Keep it working for at least one minor release, and preferably one that people have had time to adopt. A deprecation and a removal in the same release is a removal.
Remove in a major version, and only then.
The mechanism is @warnings.deprecated (PEP 702), and the reason it is the
standard now is that it reaches users through three channels rather than
one:
from warnings import deprecated
@deprecated("Use fetch_bytes() instead; removed in 3.0")
def fetch(url: str) -> bytes: ...
- Their type checker flags every call site — statically, across the whole codebase, before anything runs.
- Their IDE strikes it through.
-
The runtime raises a
DeprecationWarningwhen it is called.
That third channel is the weakest, and it is the one everyone reaches for first.
DeprecationWarning is hidden by default outside __main__, so a
deprecation in a library called from a library called from an application is
invisible to the person who needs to act on it. It shows up under pytest
(which enables it) and under -W default, and nowhere else. Which is exactly
why the static channel is the one that matters: it works whether or not the code
path is exercised.
A detail worth knowing: @deprecated(..., category=None) emits no runtime
warning at all and keeps only the static channels. That is often the right
setting for something called in a hot loop, or for a class whose __init__ you
do not want to slow down.
💡Your library's Client.get() is deprecated in favour of Client.request("GET", ...). You add @deprecated, ship it, and six months later nobody has migrated. What went wrong, and what would have worked?
click to reveal
Most likely: the deprecation only ever fired at runtime, in a code path most users’ test suites do not exercise, as a warning category their configuration suppresses. It was never seen.
Three things that would have changed the outcome:
-
@warnings.deprecatedrather than a manualwarnings.warn, because the type-checker channel does not depend on the code being run. A user with mypy in CI sees it the day they upgrade. -
stacklevel=2if you did write the warning by hand — without it the warning points at a line inside your library, so the user sees a file they do not own and cannot act on. This single missing argument is the most common reason deprecation warnings are ignored. -
A migration note in the changelog with a mechanical recipe — ideally a
ruff/libcstcodemod, or at minimum a sed-able before/after. “Userequest()instead” is a task;s/\.get\((.*)\)/.request("GET", \1)/is a pull request.
And the meta-lesson: adoption is a distribution problem, not a documentation problem. Count the call sites you can see (a code search across your org, or PyPI’s public dataset) rather than assuming silence means migration.
The checklist
Before you tag a release:
-
Every public module has an accurate
__all__. -
py.typedis in the package and in the built wheel, verified from a clean install. - Nothing removed that was not deprecated in the previous minor release.
- Every deprecation names its replacement and its removal version.
- The changelog lists behaviour changes, not just signature changes — exception types, orderings, defaults.
-
Your own
mypy --strictruns against the installed package from a consumer module, not against your source tree.
The last one catches the two failure modes that are otherwise invisible from
inside the repository: a missing py.typed, and a name that is importable in
your tree only because your tree is on sys.path.