Skip to content
← All Python articles

Python explainer

Change a Library's API Without Breaking Its Users

About 4 min to read. Allow extra time to work through examples.

Add detail to errors without breaking old handlers, and say which names and behaviors are public. Warn users, and give them a release to move, before you remove anything.

This article uses Python. You can make it your practice mode to browse related content across the site.

This article and the next are optional reading, for when you publish a library that other people install.

You maintain a small parsing library. Version 1.4 raises ValueError when the input is bad, and users ask for the line and column of the error. You can add them in 1.5 without breaking anyone who already catches ValueError, by raising a subclass:

class ParseError(ValueError):
    def __init__(self, message: str, *, line: int, column: int) -> None:
        super().__init__(message)
        self.line = line
        self.column = column


try:
    raise ParseError("expected a number", line=2, column=4)
except ValueError as error:
    print(f"old handler caught: {error}")

Old code still catches the error with except ValueError. New code can catch ParseError and read error.line.

Your API is more than its signatures

Callers depend on what your functions do, not only on their names and types. Each change in this table keeps every signature the same, and each one can still break a caller:

Change Who it breaks
A different exception class except clauses that name the old class
Results sorted instead of in insertion order Callers that take the first result
A new default value for an argument Calls that leave the argument out
An extra key in a returned dict Strict schemas, == checks and saved output

Even the subclass above breaks code that checks type(error) is ValueError. Write down which of these behaviors you promise, and which can change, such as the wording of error messages. Users rely on behavior you never promised too, so judge what a change costs them even when your promises allow it.

Say which names are public

List your public names in __all__ in each public module. It decides what from yourlib import * imports, and type checkers and linters read it as your list of exports. It does not stop anyone importing other names, so also document what you support.

mypy's strict mode treats a name that a module imports as private to that module, which matters most in __init__.py. To export the name, list it in __all__, or import it as from .errors import ParseError as ParseError.

Ship an empty py.typed file inside your package, so type checkers read your annotations. The next article checks that it reaches the wheel.

Use version numbers to show what changed

Semantic Versioning gives each part of MAJOR.MINOR.PATCH a meaning. A patch release fixes bugs. A minor release adds features and can deprecate old ones. Only a major release removes or changes what you promised. Before 1.0, anything may change.

Deprecate before you remove

Keep the old name working for at least one minor release, and make it warn. @deprecated is in warnings from Python 3.13, and in the typing_extensions package for 3.12:

import sys
import warnings

if sys.version_info >= (3, 13):
    from warnings import deprecated
else:
    from typing_extensions import deprecated


def encode_text(text: str) -> bytes:
    return text.encode("utf-8")


@deprecated("Use encode_text(); encode() will be removed in version 3.0")
def encode(text: str) -> bytes:
    return encode_text(text)


with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    encode("café")
print(caught[0].category.__name__, caught[0].message)
# DeprecationWarning Use encode_text(); encode() will be removed in version 3.0

Each call to encode emits a DeprecationWarning. Python shows it by default only when the call is in the script you run. pytest shows it in its summary, so most users see it in their test runs. mypy reports each use too, but only with --enable-error-code deprecated. Deprecate a Function Without Breaking Callers, in the error-handling track, covers the decorator in more detail.

Check your understanding You deprecated a function two releases ago, and most users still call it. What should you check before you remove it? Show answer

Check that the warning reaches them, for example in their test output. Check that the replacement covers what they use the old function for. And check that your release notes show the change with a before-and-after example. Then announce the release that removes it, early enough for users to move.

Test the package users install

Build the wheel, install it into a new virtual environment, and run a small script that imports your public names from outside your project folder. A clean install proves the packaging, not the behavior, so keep tests for the promises in the table above as well. The next article, Test the Wheel You Ship, Not Your Checkout, walks through the install check.

step 15 of 16

Config, Logs and Untrusted Input

Up next Test the Wheel You Ship, Not Your Checkout