skip to content

How do you retire a project's sys.version_info shims after raising its Python floor?

level: seniorimportance: nice to knowfreq 18%

answer

  1. One of the two branches is never analysed
  2. The floor is a packaging promise
  3. Release before you delete
  4. Read the dead branch before removing it
  5. Raise requires-python, then sweep version_info

basics

~20 s

Raise requires-python first, then delete each dead branch and its alias, drop the back-port dependency only where nothing evaluates it at runtime, and trim the CI matrix. Remember the branch you are deleting was never type-checked.

solid answer

~40 s

Treat it as a release decision, not a tidy-up. Raise `requires-python` in `pyproject.toml` and cut a release so installers stop offering the package to older interpreters. Then sweep the codebase for `sys.version_info` comparisons and `TYPE_CHECKING`-guarded shims, delete the branch that can no longer be taken along with its alias, and import the standard-library name directly. Drop the back-port from dependencies only where nothing evaluates the name at runtime. Trim the CI matrix and the version the checker targets to the new floor. The trap worth naming: while both branches existed, the checker only ever analysed the one matching the version it targeted, so the branch you are now deleting has been unanalysed code — read it before deleting, because it may already have diverged from the branch everyone actually ran.

code

python · 11 lines
python
import sys
from datetime import datetime, timezone

if sys.version_info >= (3, 11):
    from datetime import UTC
else:
    UTC = timezone.utc


print(datetime.now(UTC).tzinfo is UTC)
print(sys.version_info[:2])

go deeper

for a junior

Understand what a version shim is and why it exists: code that picks one import on newer interpreters and another on older ones so the module loads everywhere.

for a middle

Be able to remove one correctly — delete the unreachable branch and its alias, import the standard-library name directly, and update the declared minimum version rather than only the code.

for a senior

Show the operational instinct: the dead branch was never analysed and may have drifted, the CI matrix and the checker target have to move with the floor, and a release must exist for the interpreters you are dropping.

for a principal

Own the timing. Weigh the carrying cost of unanalysed branches and a wide test matrix against stranding users on older interpreters, tie the decision to upstream end-of-life, and communicate it as a compatibility change rather than an internal cleanup.

### Why this is a real task and not housekeeping A version shim is code with two lifetimes: the branch everyone runs, and the branch that only the oldest supported interpreter takes. Static analysis sees exactly one of them. A checker is told which version to analyse for and resolves `sys.version_info` comparisons itself, so the other branch is skipped entirely — no name resolution, no type errors, no unused-import warnings. Combine that with a CI matrix that quietly dropped the oldest version, and the fallback branch becomes code that nothing checks and nothing runs. A payment reconciliation job showed exactly this shape. It had carried, since its 3.9 days, a shim of the form `UTC = timezone.utc` in the branch for older runtimes and `from datetime import UTC` in the newer one — the `datetime.UTC` alias arrived in 3.11. A later refactor changed how timestamps were normalised, and only the live branch was updated; the fallback kept building naive datetimes. Nothing failed loudly. The symptom was a clock-skew artefact in the reconciliation output whenever the job ran on the old interpreter in one environment, at a 1,200-request-per-minute peak where a handful of mis-bucketed timestamps were easy to dismiss as noise. The shim was not the bug, but the shim is where the bug could hide. ### The order of operations Raise the floor first, in the packaging metadata: `requires-python` in `pyproject.toml`. That is the promise that changes, and it is what makes an installer resolve an older release for users still on the old interpreter instead of handing them a package that fails at import. Cut a release before deleting the shims, so there is a known-good last version for the runtimes you are dropping. Then sweep, mechanically. Grep for `sys.version_info` and read every hit. For each, delete the branch that can no longer be taken, delete the alias it created, and replace the conditional import with a direct one. Read the dead branch before deleting it — that is where drift lives, and the diff is the last chance anyone will look at it. Then the dependencies. A typing back-port distribution can usually be dropped once the floor covers every name in use, but only where nothing evaluates the name while the program runs. If it is still needed for a name added after your new floor, it stays; if it was gated by a `python_version` environment marker, the marker may now exclude every supported version and should go with it. Then the tooling. Trim the CI matrix to the versions you still support, and raise the version the type checker is told to target — otherwise it keeps analysing your code as if the old interpreter were still in play and will happily flag your new standard-library imports. ### What else falls out with the floor A floor bump often makes other scaffolding removable at the same time, and it is worth asking about each deliberately rather than sweeping. `from __future__ import annotations` was there so hints would not be evaluated on runtimes without lazy annotations; from 3.14 that behaviour is the default, but the two mechanisms are not identical for anything that inspects annotations, so removing the future import is a behaviour change to make on purpose and with tests, not as part of a shim sweep. `TYPE_CHECKING` guards, by contrast, are usually still worth keeping: they exist for import cost and cycles, not for version skew. ### How to tell whether it went well The checker runs clean against the new floor. No `sys.version_info` comparison remains that references a version at or below it. The dependency list has shrunk or is unchanged for a stated reason. The CI matrix and the checker target agree with `requires-python`. And the release notes say which interpreters were dropped, because for a library that is the user-visible part of the change — everything else is internal. ### The judgment behind the timing Raising a floor is not free: it strands users on older interpreters and forces their hand. The counterweight is the carrying cost of the shims themselves — branches nobody analyses, a matrix that grows with every supported version, and a vocabulary ceiling on the whole codebase. The usual trigger is the upstream end-of-life of the oldest version you support, which turns the question from a preference into a security argument.

  • Why is the branch you are deleting likelier to be wrong than the one you keep?
    Because nothing has been looking at it. A checker resolves the version comparison and analyses only the branch matching its target version, so the other side gets no name resolution and no type errors. If the CI matrix also dropped that interpreter, the branch is neither checked nor executed, and any refactor that touched its sibling could have left it behind.
  • Once the floor is 3.14, can you simply delete `from __future__ import annotations` everywhere?
    Not blindly. Import-time safety is covered by PEP 649 lazy evaluation, so the crash it prevented is gone. But the two mechanisms differ for anything that introspects annotations: the future import yields strings, while 3.14 evaluates the real objects on demand. Any code that reads annotations expecting strings changes behaviour, so remove it deliberately, module by module, with tests.

It is decommissioning a backup generator nobody has started in three years. The right move is to inspect it before hauling it away, because whatever quietly broke in it was never going to announce itself.

saying these in an interview costs you the question

  • Deletes the shim branch without reading it
  • Assumes both branches were type-checked all along
  • Raises the floor in code but not in requires-python
  • Leaves the checker targeting the old minimum version
  • Drops the back-port dependency while code still evaluates the name
  • Treats a floor bump as a patch release for a library

context