Python 3.14 accepts this:
try:
load_config()
except FileNotFoundError, PermissionError:
use_defaults()
Before 3.14 the tuple needed parentheses:
except (FileNotFoundError, PermissionError):
That is the whole feature. It is worth five minutes anyway, for one historical reason and one practical one.
The Python 2 trap it closes
In Python 2, except ValueError, e: did not mean “catch ValueError or e”. It
meant “catch ValueError and bind it to e“ — the old spelling of what is
now except ValueError as e. So this, in Python 2:
except ValueError, TypeError: # binds the exception to the name TypeError
...
caught only ValueError, silently rebound the built-in name TypeError to the
exception instance for the rest of the scope, and let every TypeError
propagate. It looked exactly like the thing it was not doing.
Python 3 made that a SyntaxError, which is why the parentheses were required.
PEP 758 (accepted for 3.14) restores the unparenthesised form now that the old
meaning is long gone and cannot come back — the syntax is unambiguous because
as is the only way to bind.
💡Why could PEP 758 not simply allow except ValueError, TypeError as e: as well? What is ambiguous about it?
click to reveal
Precedence between the comma and the as.
except A, B as e: has two readings a parser could plausibly take: (A, B) as e — catch either and bind — or A, (B as e), which is not meaningful but is exactly the shape the Python 2 syntax had. Rather than pick, PEP 758 restricts the new form to the case where no as clause is present, so there is nothing to disambiguate.
The rule to remember is therefore: no as, no parentheses needed; with as, parentheses are mandatory.
except FileNotFoundError, PermissionError: # legal on 3.14+
except (FileNotFoundError, PermissionError) as exc: # parentheses required
except FileNotFoundError, PermissionError as exc: # SyntaxError
The same relaxation applies to except* groups under the same restriction.
Why this matters more than it looks
It is a syntax change, and syntax changes have a property that library changes do not: they cannot be feature-detected.
if sys.version_info >= (3, 14):
... # too late; the file already failed to parse on 3.13
A module containing except A, B: raises SyntaxError at import on 3.13, before
a single statement runs, before any try around the import can help — an
ImportError handler will not catch it either, because the failure happens
during compilation of the module body. The only mechanisms that work are
requires-python = ">=3.14" in pyproject.toml, so the installer refuses the
wheel, and keeping version-gated code in a separate module you import lazily.
This is the general lesson the ⚠ markers in this track all point at: t-strings,
PEP 701’s quote reuse, PEP 798’s comprehension unpacking and PEP 810’s lazy
are all in the same category. A library feature can be probed with hasattr
or a try: import. A syntax feature cannot be probed at all.
💡Your library supports 3.11+. A contributor's PR uses except OSError, ValueError: because their local Python is 3.14 and it reads better. CI passes. What went wrong, and what stops it recurring?
click to reveal
CI passed because the matrix is not running the oldest supported version — or is, but not on that file’s import path.
The fix has three layers, in order of reliability:
-
requires-python = ">=3.11"inpyproject.toml. This is a promise; it does not enforce anything about your own source. But it is what makes a user’s installer refuse a wheel that claims a version it cannot satisfy, so it must be right first. -
Ruff’s
target-version(or therequires-pythonit infers frompyproject.toml) with theUPandPYIrule sets. Ruff knows which syntax is available in which version and will flag a 3.14-only construct in a project targeting 3.11 — statically, on every file, in milliseconds, without needing an old interpreter installed. -
A CI job on the minimum version that actually imports the package, not just installs it.
python -c "import yourpkg"on 3.11 catches the whole class, including the constructs no linter knows about yet.
Layer 2 is the one that would have caught this PR before review.
Style
Nothing about the old form is wrong, and there is no deprecation. Existing parenthesised code stays valid forever. The practical guidance is the boring one: pick a form, put it in the formatter config or the style guide, and do not churn a codebase over it — especially since adopting the new form raises your minimum Python to 3.14 for no functional gain.