Changing an API Without Breaking Callers

The Course
•
September 19, 2026
•
7 min read

Once other people call your code, every name in its signature is a promise. This lesson is about breaking those promises slowly enough that nobody gets hurt — and about the parts of a signature you should never promise at all.

Deprecation that both tools can see

Model.fetch was renamed to Model.get. The old name has to keep working for a while, and its users have to find out. One decorator does both jobs:

from warnings import deprecated

    @classmethod
    @deprecated("Model.fetch is deprecated; use Model.get instead")
    def fetch(cls, connection: Connection, id: int) -> Self:
        return cls.get(connection, id)

warnings.deprecated arrived in Python 3.13 (it’s typing_extensions.deprecated before that), and it is two things at once. At run time it wraps the function so calling it emits a DeprecationWarning. Statically, a checker reports the call as an error — verified against the day’s files:

misuse.py:15: error: function models.Model.fetch is deprecated:
              Model.fetch is deprecated; use Model.get instead  [deprecated]

mypy needs --enable-error-code=deprecated for that; other checkers report it by default. Note the decorator order: @classmethod on the outside, closest to def last — the deprecation wraps the function, and classmethod wraps that.

What the caller sees Python
import warnings
from warnings import deprecated


class Report:
    @deprecated("Report.render_html is deprecated; use Report.render(format='html')")
    def render_html(self) -> str:
        return "<p>report</p>"

    def render(self, format: str = "text") -> str:
        return "<p>report</p>" if format == "html" else "report"


report = Report()

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    print("result:", report.render_html())
    print("warning:", caught[0].category.__name__)
    print("message:", caught[0].message)

print("still the same object:", report.render_html() == report.render(format="html"))
DeprecationWarning is hidden by default

Python shows DeprecationWarning only in __main__ and in tests. That’s deliberate — an end user can’t act on a warning about a library’s own dependency — but it means your users may never see it. Say it in the changelog and the release notes too; treat the warning as the last line of defence, not the announcement.

Test deprecations like any other behaviour:

        with self.assertWarns(DeprecationWarning):
            post = Post.fetch(self.connection, 1)

That test also pins the fact that the old name still works, which is the part everyone forgets to check.

Parameter names are part of the API

Any parameter that can be passed by keyword is a name you have promised. Two markers decide what you’re committing to:

    def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Cursor: ...
    #                                                            ^ positional-only

    def __init__(self, *, null: bool = False) -> None: ...
    #                   ^ keyword-only
  • Everything before / can never be passed by keyword, so you can rename it freely. Use it for arguments whose meaning is obvious from position.
  • Everything after * must be passed by keyword. Use it for options — null=True at the call site says what it does, where a bare True wouldn’t, and you’re then free to reorder or add options without breaking anyone.
What each marker allows Python
def positional_only(sql, parameters=(), /):
    return f"{sql} {parameters}"


def keyword_only(*, null=False, max_length=None):
    return f"null={null} max_length={max_length}"


print(positional_only("select 1", ()))
print(keyword_only(null=True))

for call, description in [
    (lambda: positional_only(sql="select 1"), "positional_only(sql=...)"),
    (lambda: keyword_only(True), "keyword_only(True)"),
]:
    try:
        call()
    except TypeError as error:
        print(f"{description}: TypeError: {error}")

The fields in this library take every option as keyword-only, which is why TextField(max_length=100, null=True) reads the way it does — and why adding a third option next month can’t break a caller.

Say what’s public

Three declarations, none of which cost anything:

  • __all__ in a module lists the names from module import * exports, and more importantly tells readers and tools which names are the API. Some checkers treat anything not in __all__ as private when checking re-exports.
  • py.typed is an empty file in your package directory. Without it, checkers ignore your annotations entirely in other people’s code — they assume a package without the marker has unchecked types. Shipping a fully typed library without py.typed is the most common way for all this work to have no visible effect.
  • @final on a class or method says “don’t subclass this / don’t override this”. It’s checked statically, and it’s how you keep freedom to change internals that you’d otherwise have to support forever.
from typing import final


@final
class Cursor:                    # nobody may subclass this
    ...
An underscore is still your main tool

_instances, _title, _check — the leading underscore remains the cheapest and clearest “not yours”. None of the above replaces it; they make it visible to tools as well as to people.

Exercise

Retire a parameter

find used to take title. It now takes where, a dict. Keep the old call working for one release, warn about it, and make the new one the only thing the signature advertises.

import unittest
import warnings

POSTS = [
    {"title": "Hello", "views": 3},
    {"title": "Goodbye", "views": 9},
]


def find(where=None, title=None):
    """TODO:
    - `where` is a dict of column -> value, and is the supported API
    - `title=...` still works, means where={"title": ...}, and warns
      (DeprecationWarning), naming what to use instead
    - passing both is a TypeError
    - `where` should be keyword-only... but think about whether it can be,
      given how the old API was called. The tests decide.
    """
    return [post for post in POSTS if all(post[k] == v for k, v in (where or {}).items())]


class Find(unittest.TestCase):
    def test_the_new_api(self):
        self.assertEqual(find({"title": "Hello"})[0]["views"], 3)

    def test_the_old_api_still_works(self):
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            self.assertEqual(find(title="Hello")[0]["views"], 3)

    def test_the_old_api_warns(self):
        with self.assertWarns(DeprecationWarning) as caught:
            find(title="Hello")
        self.assertIn("where", str(caught.warning))

    def test_both_at_once_is_an_error(self):
        with self.assertRaises(TypeError):
            find({"title": "Hello"}, title="Hello")

    def test_no_arguments_returns_everything(self):
        self.assertEqual(len(find()), 2)


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Find)
)
Hint 1: the idea

Three behaviours, one at a time: the old keyword still works but warns, naming what to use instead; passing both is an error; and the signature makes where the obvious thing to pass. Whether where can be keyword-only is decided by the tests — look at how the first one calls find before you add a *.

Hint 2: a first step

Handle title first: when it’s given, call warnings.warn(...) with a DeprecationWarning whose message mentions where, and set where from it. Then add the both-at-once check above that. Pass stacklevel=2 as well: it makes the warning point at the caller’s line rather than at find itself, which is the line a user needs to change.

Show one solution
def find(where=None, *, title=None):
    if title is not None:
        if where is not None:
            raise TypeError("pass either where= or title=, not both")
        warnings.warn(
            "find(title=...) is deprecated; use find(where={'title': ...}) instead",
            DeprecationWarning,
            stacklevel=2,
        )
        where = {"title": title}
    return [post for post in POSTS if all(post[k] == v for k, v in (where or {}).items())]

Four decisions worth stating.

where can’t be keyword-only, which is the trap in the hint: the first test calls find({"title": "Hello"}) positionally, and that is the API you’re advertising. title stays keyword-only, so the two can never be confused.

It could be positional-only (where=None, /), and that would buy the freedom to rename it later — but then the deprecation message can’t tell people to write where=..., because they can’t. The test asks for a message naming where, so this version keeps the name promised. That’s the trade: a name you can advise people to use is a name you now owe them.

stacklevel=2 points the warning at the caller’s line instead of at this warnings.warn call. Without it, every user’s warning says find.py:14, which tells them nothing about which of their calls to fix. It is the single most commonly forgotten argument in the warnings module.

Raising TypeError for both-at-once rather than quietly preferring one: a caller who passes both has a bug, and guessing which they meant makes it their problem later instead of now.

When the deprecation period ends, deleting the five lines leaves exactly the function you wanted.

What you learned

  • warnings.deprecated warns at run time and errors in a checker, from one decorator; DeprecationWarning is hidden outside __main__ and tests.
  • stacklevel=2 is what makes a warning point at the caller.
  • / and * decide which parameter names you’re promising; options should be keyword-only.
  • __all__, py.typed and @final publish the boundary — and without py.typed, none of your annotations reach your users at all.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.