Python versions
py2axum supports projects running on Python 3.12, 3.13, 3.14 and 3.15. Messages and behaviours that changed
between these versions follow the Python the project runs on, so the binary answers what your Python
answers: ZeroDivisionError texts, the TypeError of an unhashable dict key, base64 and base32 decoding,
datetime.strptime, the MIME types FileResponse guesses, and more (the full list is below).
This page reviews every change of
What's New in Python 3.14 and
What's New in Python 3.15 that can show up in a FastAPI,
Pydantic or SQLAlchemy application. It also lists the message changes those pages do not mention, found by
running the same code on each Python. Python 3.15.0 was released on 9 October 2026; the latest 3.14 is 3.14.8.
Which Python the binary follows
In order:
--python X.Y (on py2axum, py2axum check and py2axum watch), or the PY2AXUM_TARGET_PYTHON
environment variable;
- the project's
.python-version (as uv and pyenv write it), at --root or above;
- the Python running py2axum, when the project's
requires-python (in uv.lock or pyproject.toml)
allows it. This is the usual case of a Docker build, where py2axum runs on the image's Python;
- else, the lowest Python that
requires-python allows.
The Python running py2axum must be at least as recent as the project's: the parser only knows its own
syntax. A Python outside 3.12–3.15 is refused (P2A0601).
A few behaviours changed inside a minor: strptime('%e') is accepted from 3.13.6, and the security releases
3.13.13 and 3.14.4 rewrote base64's padding rules and a JSON escape message. Distributions backport such
fixes without changing the version (Debian's 3.13.5-2+deb13u5 has the new base64 rules, not the other two).
So when py2axum runs on the project's minor and no patch release is named, it probes these behaviours on
itself and the binary reproduces what it found. Otherwise the binary follows the patch release named by
--python 3.13.5 or .python-version, else the newest; PY2AXUM_PYTHON_VERSION (e.g. 3.13.5) overrides
both when the binary starts. py2axum check prints the Python it targets and what it probed.
| Probed behaviour |
Changed in |
Without a probe |
base64.b64decode padding rules (data after = decoded) |
3.13.13, 3.14.4 |
the named patch, else the newest |
datetime.strptime accepts %e |
3.13.6 |
the named patch, else the newest |
json.loads('"\\u0041'): unterminated string (was "Invalid \uXXXX escape") |
3.13.13, 3.14.4 |
the named patch, else the newest |
The probe measures the Python that translates, not the one in production. Translate with the same Python
as production: the same image (the builder stage of a Dockerfile FROM the production image), or the same
python:3.13 official build. Translating on Debian's python3 for an application served by the official
python:3.13 image (or the reverse) can pick the other side of these three behaviours; name the patch
release then (--python 3.13.5 or .python-version).
sys.version_info
sys.version_info is read at translation time, against the project's Python:
- comparisons with a tuple of integers (
sys.version_info >= (3, 14)), .major, .minor, [0], [1] and
[:2] become constants;
- an
if whose test is decided this way keeps only the branch taken. A module imported only under a test
the project's Python fails is never translated, and never needs to parse. So a project can hold 3.15 syntax
behind if sys.version_info >= (3, 15): and still be translated for 3.14;
- what depends on the patch level (
.micro, sys.version_info >= (3, 14, 2) for a 3.14 project,
sys.version, sys.hexversion) is refused with file:line.
Status
| Status |
Meaning |
| Supported |
Translated with the behaviour of the project's Python; a conformance case proves it (fixtures/dynapp: pyver.py on every Python, py314.py and py315.py on the Pythons that have their syntax) |
| Refused |
Refused at translation with file:line and an error code, or by a run-time TypeError of py2axum where the value is only known then (noted). --python-side leaves the route to Python |
| Difference |
Translated, with a documented difference |
| N/A |
Not observable by an HTTP client of a web API (packaging, interpreter internals, command-line tools, platforms), or concerns a library py2axum does not translate |
| To do |
Not reproduced or not checked yet: listed for a later version |
Python 3.14
Syntax and semantics
| Change |
Status |
Notes |
Template strings t"..." (PEP 750) |
Refused (P2A0117) |
No string.templatelib in the runtime; use an f-string |
except A, B: without brackets (PEP 758) |
Supported |
/py314/except |
return/break/continue leaving a finally (PEP 765) |
Supported |
Only a SyntaxWarning, same behaviour; /py314/finally |
| Deferred evaluation of annotations (PEP 649, 749) |
Supported |
A model can name a class defined after it, unquoted (/py314/forward); an annotation naming an undefined name is never evaluated (/py314/lazy-annotation) |
annotationlib |
Refused (P2A0201) |
Not in the library map |
from __future__ import annotations (deprecated, unchanged) |
Supported |
As before |
More SyntaxErrors (__debug__, misplaced await) |
N/A |
The project does not parse on 3.14 either |
| Mixed real/complex arithmetic (C99 rules) |
N/A |
Complex arithmetic is not translated |
json decoding is bounded by the C stack instead of 10 000 levels (Stack overflow (used 8144 kB) while decoding a JSON array… past ~50 000 levels on an 8 MiB stack) |
Difference |
The binary keeps 10 000 levels (RecursionError: maximum recursion depth exceeded while decoding a JSON array from a unicode string): a limit on untrusted input (Supported subset, Security). A document between the two depths passes json.loads in Python, then usually exceeds the recursion limit elsewhere (FastAPI's jsonable_encoder): the same 500, another traceback |
Builtins and error messages
| Change |
Status |
Notes |
Unhashable dict key / set element: cannot use 'list' as a dict key (unhashable type: 'list') |
Supported |
Since 0.3 |
math domain errors name the expected input (expected a nonnegative input, got -1.0) |
Supported |
Since 0.3 |
json errors carry notes (when serializing list item 1, when serializing dict item 'a') |
Supported |
/pyver/json_notes; __notes__, add_note() and the notes printed by traceback.format_exception* are supported on every Python (/pyver/notes) |
_ and , separators in the fractional part of a format spec (f"{x:.6_f}") |
Supported |
/pyver/fmt_frac; a ValueError before 3.14, as in CPython |
bytes.fromhex() accepts bytes |
Refused (P2A0203) |
bytes.fromhex is not in the runtime |
float.from_number(), complex.from_number() |
Refused (P2A0203) |
|
map(..., strict=True) |
Refused (P2A0209) |
|
NotImplemented in a boolean context raises TypeError |
Refused (P2A0105) |
NotImplemented is not translated |
int() no longer falls back to __trunc__() |
N/A |
Number protocols of project classes are not translated |
Three-argument pow() tries __rpow__; functools.partial as a method descriptor; super objects copyable |
N/A |
Not translated |
repr(Union[int, str]) is int \| str; Union and UnionType are one type |
Refused (run time) |
The text of a type expression (str(), repr(), f-string) raises a py2axum TypeError: it depends on the Python and on the classes' modules |
Clearer SyntaxErrors (keyword typos, elif after else...) |
N/A |
The project parses |
with/async with on a value without that protocol names the missing method and suggests the other statement |
Supported |
/pyver/cm/{kind}; the type is named like CPython (_thread.lock; a class written in Python with its module from 3.14, asyncio.locks.Semaphore). Difference: from 3.14, a project class is named without its module (A, where CPython says app.main.A) |
Standard library
| Change |
Status |
Notes |
uuid.uuid7() (RFC 9562) |
Supported |
CPython's algorithm (48-bit ms time, 42-bit counter monotonic within a millisecond); /py314/uuid7; refused for an older project (P2A0603) |
uuid.uuid6(), uuid.uuid8(), uuid.NIL, uuid.MAX |
Refused (P2A0201) |
|
contextvars.Token as a context manager (with var.set(x):) |
Supported |
/pyver/token_with; TypeError before 3.14, as in CPython |
Path.copy(), copy_into(), move(), move_into() |
Supported |
/pyver/path_copy; follow_symlinks=False and preserve_metadata=True raise a py2axum TypeError. Path.info is refused |
re: \z is \Z; \B matches an empty string |
Supported |
/pyver/re_z, /pyver/re_B_empty; \z is a bad escape before 3.14 |
mimetypes: many new types (fonts, .yaml, .7z, .docx...) |
Supported |
FileResponse guesses with CPython's built-in table of the project's Python, then the host's mime.types files, like mimetypes.init(); /pyver/mime/{name}. mimetypes.guess_type itself is refused (P2A0201) |
pickle default protocol 5 |
Supported |
Since 0.3 |
date.strptime(), time.strptime() |
Refused (P2A0201) |
datetime.strptime is supported |
Decimal.from_number(), Fraction.from_number(), Fraction(obj with as_integer_ratio) |
Refused |
|
functools.Placeholder, reduce(initial=) |
Refused (P2A0201) |
functools.reduce is not in the library map |
heapq max-heap functions |
Refused (P2A0201) |
|
operator.is_none(), is_not_none() |
Refused (P2A0201) |
|
os.reload_environ() |
Refused (P2A0201) |
|
compression.zstd, compression.gzip/zlib/bz2/lzma |
Refused (P2A0201) |
gzip and zlib under their usual names are supported |
concurrent.interpreters, InterpreterPoolExecutor |
Refused (P2A0201) |
|
asyncio: create_task(**kwargs), call graphs, get_event_loop() raising without a loop |
N/A |
A route always runs inside the loop; the call-graph functions are not translated |
logging.handlers.QueueListener as a context manager |
N/A |
Not translated (the logging work of 0.8) |
inspect.signature(annotation_format=), io.Reader/Writer, ast additions |
N/A |
Not translated |
unicodedata 16.0 |
Supported |
unicodedata.normalize/combining and str.isalpha()... follow the project's Python: the runtime's tables (Unicode 17.0) less what that Python's Unicode does not assign (py2axum/ucdages.py, from tools/gen_ucdages.py), even when the translating Python is newer; /libs/unicodedata |
hmac, ssl, http.server, tarfile, zipfile, configparser, argparse, sqlite3, unittest, getpass, faulthandler... |
N/A |
Not used by a request handler, or not translated |
Deprecations, removals, runtime
| Change |
Status |
Notes |
Removals (asyncio child watchers, ast.Num/Str, pathlib extra arguments, urllib URLopener, itertools pickling, email.utils.localtime(isdst=)...) |
N/A |
None of them is translated |
Deprecations (asyncio.iscoroutinefunction, the policy system, codecs.open...) |
N/A |
A warning only |
| Free-threaded build officially supported (PEP 779) |
N/A |
The binary has no GIL either; an endpoint's results do not depend on it |
| Incremental GC in 3.14.0–3.14.4 (back to generational in 3.14.5) |
N/A |
|
Tail-call interpreter, JIT, -X context_aware_warnings, -X thread_inherit_context, remote debugging |
N/A |
|
Python 3.15
Syntax and semantics
| Change |
Status |
Notes |
Unpacking in comprehensions: [*x for x in xs], {*x for ...}, (*x for ...), {**m for m in ms} (PEP 798) |
Supported |
/py315/unpack, with CPython's errors (Value after * must be an iterable, not int, 'int' object is not a mapping); ** of a mapping that is not a dict raises a py2axum TypeError |
lazy import x, lazy from m import n (PEP 810) |
Supported for libraries; Refused (P2A0117) for project modules |
A library import has no observable start-up order; a project module's top-level code would run at start-up in the binary, at its first use in Python. __lazy_modules__ and sys.set_lazy_imports() are not translated |
Unary plus in match literal patterns (case +1:) |
Supported |
/py315/match |
| UTF-8 is the default encoding (PEP 686) |
Supported |
The binary always reads and writes UTF-8 without encoding= |
module.__cached__ removed, ImportError repr, import locks |
N/A |
|
Builtins and error messages
| Change |
Status |
Notes |
frozendict (PEP 814) |
Refused (P2A0105) |
|
sentinel (PEP 661) |
Refused (P2A0105) |
|
bytes.replace(..., count=) as a keyword |
Supported |
/pyver/bytes_replace_kw (a TypeError before 3.15, as in CPython); bytes.replace itself was missing from the runtime and is now supported on every Python |
bytearray.take_bytes(), slice[...], memoryview complex formats, __slots__ changes |
N/A |
Not translated |
Timestamps and timeouts accept any real number (Decimal, Fraction) |
Supported |
datetime.fromtimestamp (/pyver/fromtimestamp_decimal, with 3.14's reworded error before: argument must be int or float, not decimal.Decimal) and socket.create_connection(timeout=) (/pyver/timeout_*: must be real number, not str). The other C functions taking a timestamp or a timeout (time.sleep, time.localtime, date.fromtimestamp, threading, select...) are not translated (P2A0201). asyncio's delays are Python code, unchanged: asyncio.sleep/wait_for add the delay to the loop's float time (a Decimal fails, except asyncio.sleep on uvloop), /pyver/aio/* |
AttributeError hints from other languages (Did you mean '.append'?) |
Difference |
str(exc) is unchanged; traceback.format_exception_only adds the hint in Python (on every Python for a close name, from 3.15 for these), not in the binary. Reading an attribute no runtime type has is refused anyway (P2A0204) |
Standard library
| Change |
Status |
Notes |
base64.urlsafe_b64decode no longer requires padding; padded= |
Supported |
/pyver/b64_unpadded, /py315/padded; python-jose and PyJWT follow it (their base64url_decode) |
base64.b32decode rewritten in C (new messages: Only base32 data is allowed, Excess padding not allowed...) |
Supported |
/pyver/b32_*; pyotp follows it |
Other new base64/binascii parameters (wrapcol=, ignorechars=, canonical=), binascii base32/85 functions |
Refused (P2A0209, P2A0201) |
The base64 functions accept only the parameters the runtime reads |
re.prefixmatch(), Pattern.prefixmatch() |
Supported |
/py315/prefixmatch; refused for an older project (P2A0603) |
datetime.strptime: %d without a year is an error |
Supported |
/pyver/strptime_d (and %e, accepted from 3.13.6) |
os.urandom(-1) (behind secrets.token_*): size cannot be negative |
Supported |
/pyver/token_negative |
json.loads(array_hook=), json accepting frozendict |
Refused (P2A0209) |
|
urllib.parse.urlsplit(missing_as_none=), urlunsplit(keep_empty=) |
Refused (P2A0209) |
|
math.integer, math.isnormal/issubnormal/fmax/fmin/signbit |
Refused (P2A0201) |
|
asyncio.TaskGroup.cancel() |
Refused (P2A0201) |
asyncio.TaskGroup is not translated |
contextlib.ContextDecorator keeps the context open across a decorated generator or coroutine |
N/A |
@contextmanager used as a decorator is not translated |
collections.Counter ^ (symmetric difference) |
Supported |
/pyver/counter_xor; before 3.15 the TypeError names Counter (the runtime said dict on every Python) |
functools.singledispatchmethod, threading iterator helpers, pprint(expand=) |
Refused |
|
typing: TypeForm, TypedDict(closed=, extra_items=) (PEP 728), @disjoint_base |
Refused |
A TypedDict as a request or response type is refused (P2A0410); elsewhere it is a dict, unchanged |
email refuses to flatten a non-ASCII address it cannot encode (HeaderWriteError) |
N/A |
Only under a policy with header objects (EmailMessage, policy.default), not translated (P2A0201). The compat32 messages the runtime builds (MIMEText, MIMEMultipart...) encode such an address whole on every Python, unchanged (/mail/build?to=...) |
unicodedata 17.0 and its new functions |
Refused (P2A0201) for the new functions; Supported for normalize, combining |
See 3.14 |
importlib.metadata, hashlib, http.client, http.cookies, ssl, sqlite3, tomllib 1.1, xml, zlib, os.statx, Path.mkdir(parent_mode=)... |
N/A |
Not used by a request handler, or not translated |
Deprecations, removals, runtime
| Change |
Status |
Notes |
Removals (typing.NamedTuple keyword syntax, TypedDict("TD"), sre_*, glob0, CGI server, PurePath.is_reserved, Loader.load_module...) |
N/A |
None of them is translated |
Deprecations (profile, -b, .pth imports, hashlib(string=)...) |
N/A |
|
profiling package, upgraded JIT, mimalloc, frame pointers, free-threading by default on macOS installers |
N/A |
|
Changes the What's New pages do not list
Found by running the same code on 3.12, 3.13, 3.14 and 3.15 (/pyver/{name} reproduces each one):
| Change |
From |
Status |
ZeroDivisionError says division by zero for every operator and type (integer modulo by zero, float floor division by zero, float divmod()... before; float modulo became float modulo by zero in 3.13) |
3.14 |
Supported |
0.0 ** -1: zero to a negative power (was 0.0 cannot be raised to a negative power) |
3.14 |
Supported |
too many values to unpack (expected 2, got 3) for a list, tuple or dict (a str or an iterator: unchanged) |
3.14 |
Supported |
dict([1]): TypeError: object is not iterable with the note Cannot convert dictionary update sequence element #0 to a sequence |
3.14 |
Supported |
date/time/datetime range errors name the value (month must be in 1..12, not 13, day 29 must be in range 1..28 for month 2 in year 2025) |
3.14 |
Supported |
list.index: list.index(x): x not in list |
3.14 |
Supported |
in on a non-container: argument of type 'int' is not a container or iterable |
3.14 |
Supported |
str/bytes methods called without their argument: count expected at least 1 argument, got 0 (was count() takes at least 1 argument (0 given)) |
3.13 |
Supported (and bytes from 3.15 for replace) |
str.replace(old, new, count=n) accepted as a keyword |
3.13 |
Supported (it was ignored by the runtime before) |
datetime.strptime accepts %e |
3.13.6 |
Supported |
base64.b64decode: data after the padding is decoded, excess = skipped (non-strict) |
3.13.13, 3.14.4 |
Supported (/pyver/b64_after_padding) |
%-formatting errors name the argument and the position (format argument 2: %d requires a real number, not str, not enough arguments for format string (got 1), stray % at position 3) |
3.15 |
Supported (/pyver/pct_*) |
socket.getaddrinfo (and create_connection, asyncio.open_connection): the idna codec's check of the host raises UnicodeEncodeError naming the position (label too long, label empty), where 3.12 raises UnicodeError: label empty or too long |
3.13 |
Supported (/libs/ssrf, /stdmore/sock); before, the binary passed such a host to the C library |
re.error is named re.PatternError (re.error an alias) |
3.13 |
Supported: type(e).__name__ and tracebacks follow the project's Python (/pyver/re_error_name, /py314/pattern-error); re.PatternError is refused for a 3.12 project (P2A0603) |
socket.create_connection(timeout=) (C settimeout): 'Fraction' object cannot be interpreted as an integer (3.13: integer, 3.14: integer or float and fractions.Fraction); out of range: timestamp out of range for platform time_t for a float, too large to convert to C _PyTime_t for an int (PyTime_t from 3.13), then timestamp out of range for C PyTime_t for both from the security releases 3.13.15 and 3.14.7 (probed on a distribution's Python: pytime_range) |
3.13, 3.14 |
Supported (/pyver/timeout_*); before, a NaN or out-of-range timeout aborted the request |
Differences found and fixed on every Python
The review also found places where the binary differed from Python whatever its version, now fixed:
format() (the builtin was not translated; f"{n:_}" grouped with commas; Unknown format code lacked the
code), bytes.replace (missing), dict(seq) element errors, divmod() by zero, list.count()/"a".count()
without an argument (a crash of the request), base64.b32decode(map01=) (ignored), the type named by an
operator error on a Counter or a defaultdict, the type named by a context-manager error (_thread.lock),
the datetime.fromtimestamp error, and the MIME types of FileResponse (a 20-entry table instead of
CPython's, and text/plain for an unknown type where Starlette 1.1+ says application/octet-stream).