skip to content

How do you stage a migration off stdlib modules PEP 594 removed before an upgrade?

level: seniorimportance: should knowfreq 32%

answer

  1. Two releases of notice, silently delivered
  2. Warning and breakage sit on different versions
  3. Do the diagnosis before you upgrade
  4. Diff imports against the target's stdlib list
  5. Replace modules first, bump interpreter last

basics

~20 s

Do the diagnosis on the interpreter you are leaving: PEP 387 guarantees two releases of DeprecationWarning, but it is filtered out by default. Surface it, diff your imports against the target's sys.stdlib_module_names, then replace modules one at a time before bumping the interpreter.

solid answer

~50 s

PEP 387 requires a deprecation to warn for at least two feature releases before removal, and PEP 594 applied that to a batch of legacy standard-library modules deprecated in 3.11 and removed in 3.13. The trap is that the warning and the breakage live on different interpreters: on the old one you get a silent `DeprecationWarning`, on the new one a hard `ModuleNotFoundError` at import time that no filter can soften. So the plan is: surface deprecations on the current interpreter with a scoped `error`/`default` filter, diff every module the codebase imports against `sys.stdlib_module_names` evaluated on the target interpreter, then land replacements — a maintained extraction, a stdlib successor or a small in-repo shim — one at a time while still on the old version. Only after that do you bump the interpreter, as its own deploy.

code

python · 6 lines
python
import sys

# Module names an import scan found, checked against the interpreter you are
# upgrading to. Run this WITH the target interpreter.
imported = {"json", "asyncio", "sqlite3", "not_in_the_stdlib_anymore"}
print(sorted(name for name in imported if name not in sys.stdlib_module_names))

go deeper

for a junior

Know that the standard library does remove modules, that Python warns for a couple of releases first, and that importing a removed module fails outright rather than warning.

for a middle

Explain the mechanics: the deprecation period PEP 387 requires, why the warning is invisible unless you switch filters on, and how to list what a given interpreter's standard library actually contains.

for a senior

Show a sequenced plan you have run: inventory on the old interpreter, classify what warns versus what changes silently, land replacements individually so each is revertible, then upgrade the runtime as its own deploy.

for a principal

Own the upgrade cadence itself: how often the fleet moves, who tracks release notes, what the supported interpreter window is, and how much risk you take by skipping a release versus paying for a yearly migration.

### Two PEPs set the rules **PEP 387** is CPython's backwards-compatibility policy: a public API may not simply vanish. It must be documented as deprecated, it should emit a `DeprecationWarning` at runtime where that is feasible, it must be announced in the release's "What's New", and the warning has to be present in **at least two feature releases** before the removal lands. With annual releases, that is roughly a two-year window between "we told you" and "it is gone". PEP 387 also recognises a *soft* deprecation: an API documented as no longer recommended, which emits **no warning** and has no scheduled removal. That distinction matters operationally — no amount of `-W error` will surface a soft deprecation, so reading "What's New" is not optional. **PEP 594** is the concrete application people ask about: the "dead batteries" clean-up that deprecated a batch of legacy standard-library modules — obsolete audio and image format helpers, elderly internet-protocol clients, and platform-specific shims for systems nobody deploys — in Python 3.11 and removed them in 3.13. It is the reference example of the policy running end to end, and it is why "the stdlib never breaks" is no longer a safe assumption. ### The failure mode this creates The warning and the breakage happen on *different interpreters*. On the release where the module still exists you get a filtered-away `DeprecationWarning`; on the release that removes it you get a `ModuleNotFoundError` at import time, which is not a warning you can filter, log or degrade around. So all the diagnostic value lives on the interpreter you are leaving, and it is invisible unless you deliberately switch the filters on. ### Staging it, on a concrete shape Take a feature-flag service whose evaluation history is rolled up by a nightly job that takes about six hours. One shot per day, and a failure halfway through leaves part of the rollup written — a partial-failure rollback, not a clean one. That constraint, not CPU, dictates the plan. **1. Inventory before you upgrade anything.** Two complementary passes. Statically, collect every module name the codebase imports and diff it against the target interpreter's own list — run `sys.stdlib_module_names` *on the new interpreter* and subtract. Dynamically, run the existing nightly on the current interpreter with deprecations promoted for first-party code (`-W ignore::DeprecationWarning -W error::DeprecationWarning:yourpackage`) or merely surfaced (`-W default::DeprecationWarning` with `logging.captureWarnings(True)`) so that a six-hour run produces a list of real call sites instead of a crash on the first one. **2. Classify what you found.** Three buckets behave differently: things that warn then disappear (catchable, and the easy case); behaviour that changes silently with no warning at all (only "What's New" and your tests will find these); and soft deprecations (no warning ever, plan at leisure). Only the first bucket is covered by your CI filter, and saying so is the mark of someone who has done the upgrade rather than read about it. **3. Land replacements on the OLD interpreter first.** Every removed module either has a maintained extraction on the package index, a stdlib successor, or a fifty-line in-repo shim. Ship those substitutions one at a time, on the interpreter you are already running, each behind its own flag. This is the step that makes the rollback survivable: if one stage of the nightly misbehaves you revert that component tomorrow, instead of reverting an interpreter upgrade that changed a hundred things at once and re-running six hours of work to find out which. **4. Run both interpreters over one full cycle.** Pin the interpreter version in the runtime image and treat the upgrade itself as a separate, boring deploy of that image. Because the job is nightly, an overlap costs calendar days, so budget them explicitly and start the inventory a release early — by the time a module is removed, its warning has been available to you for two releases. **5. Make the next one cheap.** Keep the scoped `error::DeprecationWarning` filter in CI permanently, add a scheduled job that runs the suite on the next interpreter release candidate, and record the interpreter floor and ceiling in the project metadata so an installer cannot silently pull the service onto a version nobody has tested. ### What a strong answer emphasises That the policy gives you two releases of warning, that the warning is silent by default so those two releases are wasted unless you opt in, that the diagnosis must happen before the upgrade because afterwards the signal is a hard `ModuleNotFoundError`, and that decoupling the library migration from the interpreter migration is what turns one risky jump into a series of independently revertible steps.

  • Which upgrade breakages will a scoped error::DeprecationWarning filter never catch?
    Three kinds. Soft deprecations, which PEP 387 defines as documented-but-unwarned and therefore invisible at runtime. Behaviour that changes without any deprecation — a tightened default, a stricter parser, a different exception type — which only your own tests and the "What's New" document will reveal. And anything on a code path the run never exercises, since a warning only fires when the deprecated call actually happens.
  • Why replace the removed modules before bumping the interpreter rather than in the same change?
    Because it makes each step independently revertible. A combined change puts a library substitution and a hundred unrelated interpreter differences into one deploy, so a failure gives you no way to tell which caused it and the only rollback is all of it. Landing substitutions on the old interpreter means each one is a small, testable, individually revertible change, and the interpreter bump that follows is then a boring image swap.
  • How do you keep the next interpreter upgrade from being another archaeology project?
    Make the signal continuous instead of retrospective: keep the scoped `error::DeprecationWarning` filter in CI permanently so new deprecations fail on the change that introduces them, add a scheduled run of the suite against the next release candidate, and record the supported interpreter range in the project metadata so an installer cannot pull the service onto an untested version.

saying these in an interview costs you the question

  • Assuming the standard library never removes anything
  • Planning to discover breakage by running on the new interpreter
  • Thinking a removed module still raises a DeprecationWarning
  • Bundling library replacements into the interpreter bump
  • Believing warning-free means upgrade-safe
  • Expecting a warning for every incompatible change

context