skip to content

How do you retire a public function from a Python library without breaking callers?

level: seniorimportance: should knowfreq 44%

answer

  1. It spans releases, not one commit
  2. Warn, wait, then remove
  3. The default filters work against you
  4. One argument decides who gets blamed
  5. Point at the replacement and the removal version

basics

~20 s

Keep it working, make it warn. Emit a DeprecationWarning from the function with stacklevel=2 so the caller's line is blamed, document the replacement and the removal release, ship that for at least one release, then delete it in a major version.

solid answer

~40 s

Deprecation is a release cycle, not a code change. In release N you keep the old function working — usually delegating to the new one — and add `warnings.warn("export_transcript_v1() is deprecated since 2.3; use export_transcript()", DeprecationWarning, stacklevel=2)`, plus a changelog entry and a docstring note naming the replacement and the version it disappears in. You give callers at least one full release to migrate, then remove it in a major version, since removing a public name is a breaking change. The trap is visibility: Python's default filters ignore `DeprecationWarning` except when it is attributed to code in `__main__`, so `stacklevel=2` is essential and downstream teams still need `-W error::DeprecationWarning` or `PYTHONWARNINGS` in CI to see it. Warn at the call site, never at import time, and add a test asserting the warning fires.

code

python · 18 lines
python
import warnings

def export_transcript(chat_id):
    return f"transcript-{chat_id}"

def export_transcript_v1(chat_id):
    warnings.warn(
        "export_transcript_v1() is deprecated since 2.3 and will be removed in 3.0; "
        "use export_transcript()",
        DeprecationWarning,
        stacklevel=2,
    )
    return export_transcript(chat_id)

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    export_transcript_v1(7)
print(caught[0].category.__name__, "-", caught[0].message)

go deeper

for a junior

Know that a public function is not simply deleted: it is marked deprecated with warnings.warn(..., DeprecationWarning) first, kept working for a release, and removed later. Recognising the warning in someone else's output is the bar here.

for a middle

Explain the mechanics — the stacklevel argument, warning at the call rather than at import, the message content, and why DeprecationWarning is filtered out by default outside __main__.

for a senior

Run the whole cycle: shim delegating to the replacement, tests asserting the warning, changelog and removal target, guidance for downstreams to run with warnings as errors, and evidence about remaining callers before deletion.

for a principal

Own the policy — how long support windows run, how removals are batched into major releases, how you measure who is still calling, and how you trade the cost of carrying shims against the cost of breaking teams.

## The cycle, not the commit Removing a name from a library's public surface is a breaking change, so the work is spread across releases rather than done in one edit. A standard cycle looks like this: 1. **Announce.** In the release where the replacement lands, mark the old name deprecated in its docstring and in the changelog, and say two concrete things: what to use instead, and which release removes it. "Deprecated" with no removal target is an announcement no one can plan against. 2. **Warn at runtime.** The old function keeps working — ideally as a thin shim delegating to the new implementation, so there is one behaviour, not two — and calls `warnings.warn(message, DeprecationWarning, stacklevel=2)`. 3. **Wait.** At least one full minor release, and for a widely-used library considerably longer. The clock people actually experience starts when they upgrade, not when you publish. 4. **Remove.** In a major release, delete the shim and drop the name from `__all__`. The version bump is the signal that the promise has been withdrawn. ## Getting the warning seen This is where most deprecations quietly fail. Python's default warning filters ignore `DeprecationWarning` everywhere except code attributed to `__main__`: ```pycon >>> import warnings >>> warnings.filters[0] ('default', None, <class 'DeprecationWarning'>, '__main__', 0) ``` The rationale is that a `DeprecationWarning` is aimed at *developers*, not at the end user of an application, so it stays quiet in normal runs. Two consequences follow. First, `stacklevel` matters enormously. The default `stacklevel=1` attributes the warning to the line inside your library, which is never `__main__`, so it is filtered out and the message that is printed — when it is printed — points at your source instead of the caller's. `stacklevel=2` attributes it to your caller, which both makes the default filter fire when that caller is the user's script and shows the line the developer has to change. A wrapper an extra frame deep needs `stacklevel=3`. Second, tell downstream teams how to surface warnings deliberately: running their test suite with `-W error::DeprecationWarning`, or setting `PYTHONWARNINGS=error::DeprecationWarning`, turns your notice into a failing build while they still have time to act. Many test runners enable warnings by default, but you cannot rely on that. Use the right category. `DeprecationWarning` targets the developers calling your API. `FutureWarning` is shown by default and is for behaviour changes that will affect *end users* of a running application — a changed default value, a result that will start being computed differently. `PendingDeprecationWarning` is largely historical and is ignored by default too; reaching for it usually means you wanted `DeprecationWarning` a release earlier. ## Where to put the warning Warn from the call, not from the import. A module-level `warnings.warn` fires for everyone who imports the package, including people who never touch the deprecated name, and it cannot be attributed to any useful caller line. If the deprecated thing is a class, warn in `__init__`; if it is a parameter rather than a whole function, warn only when the caller actually passes it, which is easy to detect by giving it a sentinel default and checking for it. Write the message like a work item: the name, the version that deprecated it, the replacement, and the version that removes it. "Deprecated" alone forces every downstream maintainer to go read your source. ## Test it, and count who is affected Assert the warning in your own suite, with `warnings.catch_warnings(record=True)` and `warnings.simplefilter("always")`, or with `unittest.TestCase.assertWarns`. That does two jobs: it proves the shim still warns after refactoring, and it stops the shim's own tests failing when your CI runs with warnings as errors. Keep a test that the shim still returns the right answer too — a deprecated function that silently broke during the grace period is worse than an outright removal. Before you finally delete, get evidence. For an internal library that means grepping the monorepo or querying whatever import telemetry you have; for a public one it means the issue tracker and, if the surface is large, a release that raises the warning to an error behind an opt-in flag. The point of the cycle is that removal is uneventful, and it is uneventful only when you know who is still calling. A closing caution about scope: deprecating your own library's function is the case above. Migrating off an interpreter or standard-library API that is itself going away is a different exercise, driven by someone else's timetable, and it is planned against the Python release schedule rather than your own.

  • Why does `stacklevel=2` matter so much in a deprecation warning?
    With the default `stacklevel=1` the warning is attributed to the line inside your library. That line is never in `__main__`, so the default filter drops the warning entirely, and when it is shown it points at your source rather than the code that must change. `stacklevel=2` blames the caller, which both trips the default `__main__` filter for user scripts and prints the file and line a developer can actually fix.
  • When would you raise `FutureWarning` instead of `DeprecationWarning`?
    When the audience is the end user of a running application rather than the developer calling your API — typically an upcoming change in behaviour, such as a default value or a computed result that will differ in the next release. `FutureWarning` is shown by default, so it reaches people who never read your changelog. `DeprecationWarning` is quiet by design and is the right category for an API being retired.
  • How long should the grace period be before removal?
    At least one full minor release for an internal library, and considerably longer for a public one, because the clock consumers experience starts when they upgrade rather than when you publish. Judge it by evidence rather than calendar: how many known callers remain, how hard the migration is, and whether an automated fix exists. Removal should be uneventful, which means doing it only once you know who is still calling.
  • Should a deprecated function's own tests be exempted from warnings-as-errors?
    Not exempted — inverted. Keep a test that asserts the warning is raised, using `warnings.catch_warnings` with `simplefilter("always")` or `unittest.TestCase.assertWarns`, so the expected warning is consumed rather than escalated. Keep a behavioural test too: a shim that quietly stopped returning the right answer during the grace period is worse for callers than a clean removal.

saying these in an interview costs you the question

  • Deletes the function and bumps a minor version
  • Warns at import time instead of at the call
  • Omits stacklevel, so the library's own line is blamed
  • Assumes a DeprecationWarning is visible by default
  • Writes a message that names no replacement or removal version
  • Lets the deprecated shim drift from the new implementation

context