Skip to content
← Back to explainers

Python explainer

Deprecate a Function Without Breaking Callers

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

Keep an old function working while it warns its callers, mark it so type checkers report every use, and give people a clear way to move to the new one.

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

You want to rename clean_text() to normalize(). If you delete clean_text, every program that calls it breaks on the day it upgrades your library. Instead, keep clean_text working for a while and tell its callers to move. That is a deprecation. The old name still works, and it warns that it will go away.

from warnings import deprecated


def normalize(text: str) -> str:
    return text.strip().lower()


@deprecated("use normalize() instead, clean_text() goes away in 4.0")
def clean_text(text: str) -> str:
    return normalize(text)

deprecated is in the warnings module from Python 3.13. On Python 3.12, import the same decorator from the typing_extensions package.

What @deprecated does

The decorator does three things:

  • Each call to clean_text gives a DeprecationWarning with your message. The decorator sets the stack level for you, so the warning names the caller's line.
  • It stores the message in clean_text.__deprecated__.
  • It marks clean_text as deprecated for type checkers, which then report every use without running the code.

Warn Callers with the warnings Module, earlier in this track, shows how filters decide whether Python prints the warning, hides it or raises it. With an error filter, the call raises before the body of clean_text runs. On a class, @deprecated warns when someone creates an instance or subclasses it.

mypy reports deprecated names only when you turn that check on, even in strict mode. Here app.py imports and calls clean_text:

$ mypy --strict app.py
Success: no issues found in 1 source file
$ mypy --strict --enable-error-code deprecated app.py
app.py:1: error: function labels.clean_text is deprecated: use normalize() instead, clean_text() goes away in 4.0  [deprecated]
Found 1 error in 1 file (checked 1 source file)

With @deprecated("...", category=None), a call gives no warning at runtime. The type checker still reports it. Use that only when you know your callers run a type checker.

Deprecate a parameter, not the whole function

Sometimes only one argument changes. Here timeout_s becomes timeout. Warn only when a caller passes the old name, and keep the old name working:

import warnings


def timeout_value(
    *, timeout: float | None = None, timeout_s: float | None = None
) -> float | None:
    if timeout_s is not None:
        if timeout is not None:
            raise TypeError("pass timeout or timeout_s, not both")
        warnings.warn(
            "timeout_s is deprecated, use timeout",
            DeprecationWarning,
            stacklevel=2,
        )
        return timeout_s
    return timeout

Here you call warnings.warn yourself, so you pass stacklevel=2 to name the caller's line. Decide what happens when a caller passes both names. This code raises TypeError.

This code treats None as "not passed". If None is a value callers pass on purpose, use a sentinel default instead. A sentinel is a private object, such as _MISSING = object(), that no caller can pass by accident.

Find the calls before you remove the function

Run your tests with -W error::DeprecationWarning. Each deprecated call then raises, and the test that makes it fails. That finds only the calls your tests reach. mypy with --enable-error-code deprecated finds the others, because it reads the code without running it.

Tell people how to move. Put a before-and-after example in your release notes, and say which release removes the old name. If your project uses semantic versioning, add the warning in a minor release, such as 3.4, and remove the old name in the next major release, 4.0. If there is no replacement, say so, and say why.

Check your understanding Your test suite passes after you add @deprecated to clean_text. Does that mean none of your code still calls it? Show answer

No. A warning is only a message, so a test passes whether the runner prints the warning or hides it. Run the tests with -W error::DeprecationWarning, so each deprecated call fails the test that makes it. That still misses calls the tests never reach, so also run mypy with --enable-error-code deprecated, which checks every line.

The next exercise, Report Calls to Deprecated Functions, builds the report a checker gives. You get a table of deprecated names and a list of calls. You write one line for each caller that uses a deprecated name.

Going further

  • To deprecate one way of calling an overloaded function, put @deprecated under @overload on that one signature. Type checkers report calls that match it. Overloads never run, so those calls give no runtime warning.
  • @deprecated comes from PEP 702. See the warnings.deprecated documentation.

step 16 of 20

Error Handling with Exceptions, Retries and Warnings

Up next Report Calls to Deprecated Functions