Skip to content
← All articles

Publishing an API you can evolve

"Breaking change" understood as signature changes only is wrong — an exception type, an iteration order and a default argument are all breaking, and none of them change a signature. What a public surface actually consists of, how to declare it so tooling enforces it, and the deprecation cycle that reaches users through their type checker rather than a warning nobody sees.

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 DeprecationWarning when 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:

  1. @warnings.deprecated rather than a manual warnings.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.
  2. stacklevel=2 if 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.
  3. A migration note in the changelog with a mechanical recipe — ideally a ruff/libcst codemod, or at minimum a sed-able before/after. “Use request() 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.typed is 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 --strict runs 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.