Skip to content
← Back to explainers

Python explainer

Bring Old Python Code Up to Date

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

Look up the newer spelling of common old Python code and what to check before each change. Learn to update old code without changing what it does.

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

You inherit a module written a few years ago:

import os
from typing import Dict, List, Optional


def load_tags(name: str, root: Optional[str] = None) -> Dict[str, List[str]]:
    path = os.path.join(root or "config", name + ".txt")
    print("loading %s" % path)
    return {}

Every line still runs on Python 3.15. Newer Python has a shorter spelling for most of them, and this track taught several. This article puts the common ones in one table. Then it shows how to update old code without changing what it does.

Old spelling, new spelling

Old New Since Check before you change it
"%s" % x, "{}".format(x) f"{x}" 3.6 Leave logging calls and SQL queries alone. They need the template and the values kept apart.
A long if and elif chain on one value match and case 3.10 Keep if when the conditions test unrelated things.
Optional[str], Union[int, str] str | None, int | str 3.10 Nothing. mypy reads both the same way.
List[int], Dict[str, int] list[int], dict[str, int] 3.9 Nothing. mypy reads both the same way.
T = TypeVar("T") above a function def first[T](items: list[T]) -> T: 3.12 Every Python version you support must be 3.12 or newer.
os.path.join(root, name) Path(root) / name 3.4 Path does not check that a user's path stays inside a folder. You still have to.
datetime.utcnow() datetime.now(UTC) 3.11 The old value has no time zone and the new one does. Using < or - between the two kinds raises TypeError.
Hand-written __init__, __eq__ and __repr__ @dataclass 3.7 Keep any custom equality or hashing the old class had.
String constants such as "active" enum.StrEnum 3.11 Text from outside still has to be parsed, as in Status(value).
except (A, B): except A, B: 3.14 Every Python version you support must be 3.14 or newer.

The module above becomes:

from pathlib import Path


def load_tags(name: str, root: str | None = None) -> dict[str, list[str]]:
    path = Path(root or "config") / f"{name}.txt"
    print(f"loading {path}")
    return {}

A rewrite that looks the same can change the result

For a plain dict, {**a, **b} and a | b give the same result. Both merge the two, and the right side wins on a shared key. Counter, a dict subclass, gives | its own meaning:

from collections import Counter

left = Counter(a=3)
right = Counter(a=1, b=2)
assert dict(left | right) == {"a": 3, "b": 2}  # | keeps the larger count
assert {**left, **right} == {"a": 1, "b": 2}   # unpacking: right side wins

added = left.copy()
added.update(right)
assert dict(added) == {"a": 4, "b": 2}         # update adds the counts

So before you rewrite a merge, find out the real types of both sides. Unpacking with ** always builds a plain dict, even from two Counters.

Change old code in small steps

  • Change one kind of thing per commit, so a reviewer can check each one.
  • Run the tests before and after. A change of spelling, such as List[int] to list[int], needs no new test. A change of behavior, such as utcnow() or a Counter merge, does.
  • Let a tool do the mechanical part. Ruff's UP rules rewrite old annotations and old string formatting for you, based on the oldest Python version you set as its target.
Check your understanding A pull request replaces every datetime.utcnow() with datetime.now(UTC), and the tests pass. Is it safe to merge? Show answer

Only if the tests reach the places where those values meet other datetimes. The new value carries a time zone, and the old one did not. Ordering the two kinds with < or subtracting one from the other raises TypeError, and == quietly returns False. A value loaded from a database column with no time zone is the usual case. Find the code that compares, subtracts or stores those values, and test it.

Going further

The Python documentation's What's New pages list every change by version. The next article, "Write Code That Runs on Several Python Versions", covers what to do when your code has to run on an older Python too.

step 22 of 23

Pattern Matching, f-Strings and Newer Syntax

Up next Write Code That Runs on Several Python Versions