A new Python idiom is not a style choice. It is a dependency, and applying a 3.14 idiom to a library that declares requires-python = ">=3.11" is a hard break for every user on 3.11, 3.12 and 3.13 — not a deprecation, not a lint warning, an ImportError or a SyntaxError on install. Knowing what runs where is therefore not trivia. It is the difference between a change and an outage.
Where the versions stand, July 2026
| Version | Status |
|---|---|
| 3.9 and earlier | End of life — no fixes of any kind, including security |
| 3.10, 3.11, 3.12 | Security-only — no bug fixes, no new features |
| 3.13, 3.14 | Bugfix — actively maintained |
| 3.15 | Beta — feature-frozen 2026-05-07, final release 2026-10-01 |
The 3.15 dates come from PEP 790, its release schedule. Feature-frozen means the feature set is final; the beta period is for stabilisation only, so anything not already in it is 3.16 at the earliest.
This course targets 3.12 as its floor, which is also what the crackedai-connect daemon requires. That is a deliberately unambitious baseline and it is still generous, because 3.12 already gave you:
-
PEP 695 generics —
class Frame[T]:anddef first[T](xs: list[T]) -> T:, with variance inferred rather than declared, and noTypeVarboilerplate. -
The
typestatement —type Handler = Callable[[Event], None], an alias that is lazily evaluated and unambiguously an alias. -
typing.override— the decorator that catches a misspelled override. -
itertools.batched— the chunking function everyone had reimplemented. -
collections.abc.Buffer— a real type for “thing that supports the buffer protocol”. -
A fast path in
dataclasses.asdictfor dataclasses containing only immutable scalar types.
If your codebase supports 3.12 and above, you are not missing much, and almost everything in this course is available to you.
The three-way split
Here is the part that actually matters in review. “New in 3.N” hides three completely different kinds of dependency, with three completely different failure modes.
Syntax — cannot be feature-detected, fails loudly
This is the category people get wrong, and the mistake is seductive:
import sys
if sys.version_info >= (3, 15):
lazy import expensive_module # SyntaxError on 3.12
That guard does nothing. Python compiles the entire module to bytecode before executing a single line of it, so a syntax feature the interpreter does not recognise is a SyntaxError raised at import time — before your if has ever been evaluated. There is no graceful degradation and no backport. typing_extensions cannot help you, because there is nothing to import.
The only real workarounds are ugly by nature: put the new syntax in a separate module that is imported conditionally inside a function, or ship separate wheels. Both are worth it only when the feature is load-bearing. PEP 695 generics, the type statement, match, and 3.14’s t-strings are all in this category.
Library — backportable, fails at import
A new class or function is just a name in a module. Miss it and you get an ImportError — loud, immediate, and fixable from PyPI:
import sys
if sys.version_info >= (3, 13):
from typing import TypeIs
else:
from typing_extensions import TypeIs
This works because there is nothing new to parse. typing_extensions backports essentially the whole typing surface — TypeIs, ReadOnly, PEP 696 defaults, @deprecated — often years before the version that introduced it reaches your floor. tomli does the same for tomllib. The version gate here is a dependency line in pyproject.toml, not a rewrite.
Behaviour — fails silently, and this is the dangerous one
Nothing raises. The code runs on both versions. It produces different answers.
💡Your service upgrades from 3.10 to 3.11 in a routine base-image bump. Every test passes. A week later, a downstream consumer reports that a partner integration has been rejecting your records for six days. What is the most likely culprit? click to reveal
The enum __str__ change.
Python 3.11 changed how IntEnum, IntFlag and StrEnum members render. They now inherit __str__ from their mixed-in type, so str(Status.ACTIVE) gives "1" where 3.10 gave "Status.ACTIVE" — and f-strings go through the same machinery, so every f"status={status}" in your codebase changed shape overnight. (The mixed-in-type case, class Color(int, Enum), moved in the opposite direction under format(). Check the 3.11 release notes against your exact enum shape rather than assuming; the two spellings did not move together.)
Now trace the blast radius. Log lines change format, so every dashboard query that grouped on the message stops matching. Cache keys built by interpolating an enum change, so the cache silently misses and everything gets slower. And any serialisation path that reached a str() — a CSV writer, a URL parameter, a message field that was never given an explicit .value — starts emitting a different token to a partner who is validating against the old one.
Why the tests passed: because your tests almost certainly compare against the same interpolation. assert render(status) == f"status={status}" is true on both versions. It is a tautology, and it is what a large fraction of format assertions actually are.
The general lesson is the one worth taking away. Behaviour changes do not fail where you are looking. They fail at the boundary — at the log aggregator, at the partner, at the cache, at the CSV that a human opens in three weeks. So the release notes to read on a version bump are not the “What’s New” highlights, which are all features. It is the deprecations page and the “Changed” entries, filtered for anything touching a type you serialise.
Three current members of this category, each worth knowing before you bump:
PEP 686 — UTF-8 mode by default in 3.15. Today, open(path) with no encoding= uses the locale’s encoding, which is why the same script reads a file correctly on your laptop and produces mojibake in a container with a different LANG. In 3.15 the default becomes UTF-8 everywhere. That is unambiguously the right change and it will silently alter what a small number of programs read and write. The defence is available now and costs nothing: pass encoding= explicitly at every open(). Ruff’s PLW1514 will find them all for you.
PEP 649/749 — deferred annotation evaluation in 3.14. Annotations are no longer evaluated at function-definition time; they are computed lazily on access, via the new annotationlib module. For most code this is invisible and strictly better — forward references stop needing quotes and import cycles for annotation-only imports stop mattering. For anything that inspects annotations at runtime — a serialisation library, a dependency-injection container, a validation framework, your own __annotations__ walk — the timing, and whether you get an object or a string, has changed.
The 3.14 GC, which is a lesson about patch releases. Python 3.14 was released with an incremental garbage collector, and it was reverted in 3.14.5. So “3.14 has an incremental GC” was true for exactly five patch releases and is now false. If you are reasoning about a runtime characteristic rather than an API, python_requires = ">=3.14" is not a precise enough statement, and “we are on 3.14” is not a precise enough answer in an incident review.
What is coming, and what is not
Two corrections, because both are widely repeated and both are wrong.
PEP 810 lazy imports do not fix circular imports. They are landing, they are genuinely useful for CLI start-up time, and they are module-scope only. The cycle is still a cycle — you have moved the moment at which it detonates from import time to first use, which sometimes helps and sometimes converts a deterministic startup failure into a mysterious one deep in a request handler. The real fixes remain: extract the shared piece, move annotation-only imports under if TYPE_CHECKING:, or defer the import into the one function that needs it.
PEP 781, built-in TYPE_CHECKING, is still a Draft and did not land in 3.15. Keep importing it from typing.
The rule to actually follow
Write down your floor — a real requires-python in pyproject.toml, not a vague team belief — and then let the tools enforce it rather than your memory. Set python_version in [tool.mypy] and target-version in [tool.ruff] to that same floor. Ruff’s UP rules will then modernise your code up to your floor and no further, and mypy will reject a TypeIs import that your oldest supported interpreter cannot satisfy.
Configure it in one place and the version question stops being something anyone has to remember in review.