pathlib: stop using os.path
os.path works with strings. pathlib.Path works with objects. The difference matters because string paths compose poorly, while Path objects carry their operations with them.
1import os23# os.path: strings all the way, function soup4base = "/var/log"5app_dir = os.path.join(base, "myapp")6log_file = os.path.join(app_dir, "app.log")7stem = os.path.splitext(os.path.basename(log_file))[0]8parent = os.path.dirname(log_file)910print(log_file) # /var/log/myapp/app.log11print(stem) # app12print(parent) # /var/log/myapp
1from pathlib import Path23# pathlib: object-oriented, / operator for joining4base = Path("/var/log")5log_file = base / "myapp" / "app.log"67print(log_file) # /var/log/myapp/app.log8print(log_file.stem) # app9print(log_file.suffix) # .log10print(log_file.parent) # /var/log/myapp11print(log_file.name) # app.log1213# Read/write directly on Path objects14# log_file.write_text("hello")15# contents = log_file.read_text()1617# Glob and rglob18# for py_file in Path("src").rglob("*.py"):19# print(py_file)
Path.walk() (3.12): os.walk replacement
1from pathlib import Path23# Before 3.12: os.walk returns string tuples4import os5for dirpath, dirnames, filenames in os.walk("/tmp/project"):6 for filename in filenames:7 full = os.path.join(dirpath, filename) # back to string hell89# Python 3.12: Path.walk() returns Path objects10for dirpath, dirnames, filenames in Path("/tmp/project").walk():11 for filename in filenames:12 full = dirpath / filename # Path all the way through
zoneinfo: timezones without pytz (3.9)
For years, handling timezones in Python meant installing pytz and learning its quirks (always use localize(), never pass a pytz timezone to replace()). Python 3.9 ships zoneinfo in the standard library, backed by the system timezone database.
1# Before 3.9: had to install pytz2import pytz3from datetime import datetime45tz = pytz.timezone("America/New_York")6now = datetime.now(tz) # OK7also_now = datetime(2024, 1, 1, tzinfo=tz) # Wrong! Must use localize()89# The correct way with pytz:10also_now = tz.localize(datetime(2024, 1, 1)) # confusing API
1from datetime import datetime2from zoneinfo import ZoneInfo34# Python 3.9: works like a normal tzinfo -- no special API5now = datetime.now(ZoneInfo("America/New_York"))6print(now)78# Creating aware datetimes9meeting = datetime(2026, 3, 15, 14, 30, tzinfo=ZoneInfo("Europe/London"))10print(meeting)1112# Converting between timezones13in_new_york = meeting.astimezone(ZoneInfo("America/New_York"))14print(in_new_york)1516# UTC is always available17from datetime import timezone18utc_now = datetime.now(timezone.utc)19print(utc_now)
On some Linux systems without a system timezone database, you need to pip install tzdata as a fallback. This is the only external package needed and it is a pure-data package. On macOS and Windows, the system database is always available.
tomllib: built-in TOML parsing (3.11)
TOML is the config format used by pyproject.toml and Cargo, Rust's package manager. Before 3.11, reading it required a third-party package (usually tomli or toml). Python 3.11 ships tomllib in the standard library.
1# config.toml2# [server]3# host = "localhost"4# port = 80805# debug = false6#7# [database]8# url = "postgresql://localhost/mydb"9# pool_size = 1010# timeout = 30.011#12# [features]13# enabled = ["auth", "logging", "metrics"]
1import tomllib23# Must open in binary mode ("rb")4with open("config.toml", "rb") as f:5 config = tomllib.load(f)67print(config["server"]["host"]) # localhost8print(config["server"]["port"]) # 80809print(config["database"]["pool_size"]) # 1010print(config["features"]["enabled"]) # ['auth', 'logging', 'metrics']1112# Parse from string (bytes)13toml_str = b"""14[app]15name = "codetail"16version = "1.0.0"17"""18data = tomllib.loads(toml_str.decode())19print(data["app"]["name"]) # codetail
Note: tomllib is read-only. There is no built-in writer. For writing TOML, you still need tomli-w or tomllib's companion package. For most use cases (reading config files), the standard library is all you need.
functools: partial, reduce, and total_ordering
Beyond @cache, the functools module has several utilities that remove boilerplate from common patterns.
partial: pre-fill function arguments
1from functools import partial23def power(base: float, exponent: float) -> float:4 return base ** exponent56# Before: write a one-liner wrapper every time7def square(n: float) -> float:8 return power(n, 2)910# After: partial freezes some arguments11square = partial(power, exponent=2)12cube = partial(power, exponent=3)1314print(square(4)) # 16.015print(cube(3)) # 27.01617# Practical: pre-configure a logging function18import logging19logging.basicConfig(level=logging.DEBUG)2021debug = partial(logging.log, logging.DEBUG)22debug("Server started on port %d", 8080)
total_ordering: define two methods, get six
1from functools import total_ordering23# Without total_ordering: must implement __lt__, __le__, __gt__, __ge__, __eq__4# With total_ordering: define __eq__ and one of the ordering methods56@total_ordering7class Version:8 def __init__(self, major: int, minor: int, patch: int) -> None:9 self.major = major10 self.minor = minor11 self.patch = patch1213 def __eq__(self, other: object) -> bool:14 if not isinstance(other, Version):15 return NotImplemented16 return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)1718 def __lt__(self, other: "Version") -> bool:19 return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)2021v1 = Version(1, 2, 3)22v2 = Version(1, 3, 0)2324print(v1 < v2) # True -- from __lt__25print(v1 > v2) # False -- generated by total_ordering26print(v1 <= v2) # True -- generated27print(sorted([v2, v1])) # [Version(1,2,3), Version(1,3,0)]
One last note: if ordering is the primary reason you are writing a class, @dataclass(order=True) generates all six comparison methods automatically from the field order. Reserve @total_ordering for classes with custom comparison logic.