There are two ways to remove something from a public API and both of them are wrong.
Delete it, and every build that used it breaks at once, with no migration path
and no warning — a ImportError in someone’s CI at 2am with no clue what to do
next. Or leave it forever and add a line to the docstring saying “deprecated”,
which nobody reads, so the symbol accumulates users for another three years and
now you can never remove it.
The middle path is a machine-readable deprecation. Since Python 3.13 the
standard spelling is warnings.deprecated, from PEP 702.
The decorator
from warnings import deprecated
@deprecated("use Client.fetch() instead; removal in 4.0")
def fetch_sync(url: str) -> bytes:
...
What it actually does, in order of usefulness:
-
Type checkers see it. mypy reports it under the
deprecatederror code; other checkers have equivalents. This is the big one — the warning arrives at the moment the developer writes the call, in their editor, not at runtime in production six months later. -
It sets
__deprecated__to the message string, on the function or class. Tooling can read that without parsing your source, which is how a linter, a documentation generator or your own audit script can enumerate every deprecated symbol in a package. -
It emits a
DeprecationWarningat runtime. On a function, when it is called. On a class, on instantiation and on subclass creation — both, because both are ways of depending on it.
Three details that decide whether it works for you:
category=None makes it static-only. No runtime warning, no per-call
overhead, type checkers still report it. That is the right setting for a hot
path, and it is the setting most large codebases end up using for the bulk of
their deprecations.
Place it after @overload. When you deprecate one overload of a function,
the decorator goes below the @overload line, on the specific signature you are
retiring. Above it, and you have deprecated the wrong thing.
It exists in typing_extensions for 3.12 and earlier, so you can adopt it
before you can drop old interpreters.
💡Your library deprecates make_client(). You add the decorator, ship it, and six months later nobody has migrated. What went wrong?
click to reveal
Almost certainly: nobody saw it. DeprecationWarning is hidden by default unless it is triggered in __main__. Your users’ code is in a package, not in __main__, so the warning was filtered out before it reached anyone’s terminal.
This is a deliberate 2.7-era decision — end users of an application should not see warnings aimed at its developers — and it means a runtime DeprecationWarning is, by default, invisible to exactly the audience it targets.
The fixes, in order of leverage:
-
The type checker.
@deprecatedsurfaces in mypy and in editors regardless of runtime filters. For a typed codebase this is the channel that actually works. -
CI. Run your own test suite with
-W error::DeprecationWarningso your own uses of deprecated things break the build. Recommend the same to downstream users in your changelog. - The changelog and the release notes, with the removal version stated as a number. “Deprecated” with no removal date is a suggestion; “removal in 4.0” is a schedule.
A deprecation is a process, not a decorator
The decorator is one step of five. The rest is calendar work.
Step 1 — decide the replacement first. A deprecation with no migration path
is just an insult. The warning message must name the thing to use instead, in
enough detail that a reader can make the change without opening your docs:
"use Client.fetch(); it takes the same arguments".
Step 2 — deprecate in a minor release. 3.4 deprecates,
3.5/3.6/3.7 keep warning, 4.0 removes. Deprecating and removing in the
same major bump gives users a version they can never sit on.
Step 3 — state the removal version in the message. Not “will be removed soon”. A number. It converts an open-ended nag into a task with a deadline, and it lets a downstream maintainer decide whether they can skip a version.
Step 4 — keep the old thing working, correctly. A deprecated function that has quietly rotted because “it is going away anyway” turns your migration into a bug report. If you cannot maintain it, you are not deprecating, you are removing with extra steps.
Step 5 — remove it, on schedule. A deprecation you never act on trains everyone to ignore your warnings.
💡How do you deprecate a *parameter* rather than a whole function? click to reveal
There is no decorator for it, so you do it by hand, and the shape depends on what you are doing.
Renaming. Accept both, warn on the old one, and forward:
def connect(host: str, *, timeout: float | None = None, timeout_s: float | None = None) -> Conn:
if timeout_s is not None:
warnings.warn("timeout_s is deprecated; use timeout", DeprecationWarning, stacklevel=2)
timeout = timeout_s
...
stacklevel=2 is not optional — without it the warning points at your own line inside the library rather than at the caller who needs to change something.
Removing. Keep the parameter with a sentinel default, warn when it is passed at all, and ignore it. Do not simply delete it: a caller passing it positionally will get a TypeError with no explanation, and one passing it by keyword will get a different, equally unhelpful one.
Changing the default. This is the hardest, because there is no way to detect “the caller relied on the default”. The honest approach is a sentinel default (_UNSET), a warning when the parameter is not passed, and a major version to flip it. Many projects decide the churn is not worth it and change the default in a major release with a loud changelog entry instead — which is a defensible call, as long as it is a call and not an accident.
For overloads, PEP 702’s @deprecated placed after @overload handles the “this call shape is going away” case directly, and is much cleaner than any of the above when it applies.
What the check actually looks like
If you want to know whether your deprecation is working, the question is mechanical: for every reference to a symbol in the codebase, is that symbol marked deprecated, and if so, where is it used from? That is a graph walk over a symbol table and a usage list — precisely what a type checker does, and precisely what you can build in twenty lines yourself for a one-off audit.
Two things fall out of writing it once. First, deduplication matters: ten calls
to the same deprecated helper from one module is one migration task, and a
report that lists it ten times will be ignored. Second, ordering matters:
unstable diagnostic order makes a CI diff unreadable, so sort by
(caller, symbol) and mean it.
Summary
-
@warnings.deprecated(3.13, ortyping_extensionsearlier) is the standard spelling: type checkers see it,__deprecated__records it, runtime warns. -
category=Nonefor static-only. After@overload, not before. -
DeprecationWarningis invisible by default outside__main__— the type checker is your real delivery channel, and-W error::DeprecationWarningin CI is your backstop. - Name the replacement and the removal version in the message.
- Deprecate in a minor, remove in a major, and actually remove it.