Python 3.15.0 came out on 9 October 2026, and the follow-up security releases (3.15.1 on 21 October and 3.15.2 on 26 October) mean it is now a sensible time to start using it. GitHub’s Dependabot also added Python 3.15 support on 3 November, so your dependency updates will keep working after you upgrade.
Rather than list every change, this tutorial covers the five features you are most likely to use in everyday code, with examples you can paste into a 3.15 REPL. All examples follow the official What’s New in Python 3.15 page.
Install Python 3.15
Download it from python.org/downloads/release/python-3150, or use your usual version manager. With uv:
uv python install 3.15
uv venv --python 3.15
source .venv/bin/activate
python --version
1. frozendict: an immutable, hashable dict
Python has always had frozenset, but never a built-in frozen dictionary. PEP 814 fixes that. A frozendict cannot be changed after it is created, and it is hashable as long as all its keys and values are hashable.
>>> a = frozendict(x=1, y=2)
>>> a
frozendict({'x': 1, 'y': 2})
>>> a['z'] = 3
TypeError: 'frozendict' object does not support item assignment
>>> b = frozendict(y=2, x=1)
>>> a == b, hash(a) == hash(b)
(True, True)
Notes from the docs:
- It keeps insertion order, but comparison ignores order.
- It is not a subclass of
dict. It inherits directly fromobject. copy,json,pickle,pprint,marshal,decimalandxml.etree.ElementTreeaccept it.
A practical use: safe default config and cache keys
from functools import lru_cache
DEFAULTS = frozendict(timeout=30, retries=3, region="ap-south-1")
@lru_cache
def build_client(options: frozendict):
# options is hashable, so it can be an lru_cache key
return f"client({dict(options)})"
opts = frozendict({**DEFAULTS, "timeout": 10})
print(build_client(opts))
One thing to check when you upgrade: code that tests isinstance(x, dict) will return False for a frozendict. The docs suggest isinstance(x, (dict, frozendict)), or collections.abc.Mapping if you want to accept any mapping.
There is also a neat JSON trick. The new array_hook parameter on json.loads() lets you build fully immutable data from JSON:
import json
data = json.loads(
'{"user": {"roles": ["admin", "dev"]}}',
object_pairs_hook=frozendict,
array_hook=tuple,
)
print(data) # nested frozendicts and tuples
2. sentinel: proper “missing” values
When None is a valid value, people invent placeholders like _MISSING = object(). Those work, but they print as <object object at 0x...> and can’t be used neatly in type hints. Python 3.15 adds a sentinel built-in (PEP 661):
MISSING = sentinel("MISSING")
SETTINGS = {"theme": "dark", "proxy": None}
def get_setting(name: str, default: str | None | MISSING = MISSING):
if name in SETTINGS:
return SETTINGS[name] # may legitimately be None
if default is MISSING:
raise KeyError(name)
return default
Why it is better:
- It prints as
MISSING, so error messages and logs stay readable. Passrepr="<MISSING>"if you want a different display. - It works with
|in type hints, as shown above. - Copies return the same object. Pickling keeps identity for sentinels defined at module level under a matching name.
- Always compare with
is, never==.
3. Lazy imports for faster startup
Big CLIs and web apps often spend a noticeable amount of time just importing modules they may never use. PEP 810 adds a lazy soft keyword. The module is only loaded the first time you actually use the name.
lazy import json
lazy from pathlib import Path
print("Starting up...") # json and pathlib not loaded yet
data = json.loads('{"key": "value"}') # json loads here
p = Path(".") # pathlib loads here
This is perfect for a CLI where each sub-command needs different heavy libraries:
lazy import pandas as pd
lazy import matplotlib.pyplot as plt
def cmd_report(path):
df = pd.read_csv(path) # pandas only imported if this command runs
df.plot()
plt.savefig("report.png")
def cmd_version():
print("1.0.0") # fast: no pandas, no matplotlib
Rules to remember:
lazyis only allowed at module level. Using it inside a function, class ortryblock is aSyntaxError.lazy from x import *and lazy__future__imports are not allowed.- If the module is missing, the error is raised when the name is first used. The traceback points to both places.
If you still support older Pythons, list module names in __lazy_modules__ instead. Regular import statements for those modules become lazy on 3.15 and stay normal on older versions:
__lazy_modules__ = ["json", "pathlib"]
import json # lazy on 3.15+
import os # still eager
You can also try lazy loading on an existing app without editing code, using python -X lazy_imports=all app.py or the PYTHON_LAZY_IMPORTS environment variable.
4. Unpacking in comprehensions
PEP 798 lets you use * and ** inside comprehensions. Flattening no longer needs a double for or itertools.chain:
>>> lists = [[1, 2], [3, 4], [5]]
>>> [*L for L in lists]
[1, 2, 3, 4, 5]
>>> sets = [{1, 2}, {2, 3}, {3, 4}]
>>> {*s for s in sets}
{1, 2, 3, 4}
>>> dicts = [{'a': 1}, {'b': 2}, {'a': 3}]
>>> {**d for d in dicts}
{'a': 3, 'b': 2}
The merge example is handy for layered settings: later dictionaries win, just like {**a, **b}.
5. UTF-8 is now the default encoding
With PEP 686, open("file.txt") with no encoding argument now uses UTF-8 everywhere, whatever the system locale. This mostly helps Windows users, where the old default was often a legacy code page.
Two pieces of advice:
- Keep writing
encoding="utf-8"explicitly if your code must behave the same on older versions. - If you really need the old behaviour, set
PYTHONUTF8=0or passencoding="locale".
Bonus: friendlier error messages
Python 3.15 now suggests the Python equivalent when you use a method name from another language:
>>> [1, 2, 3].push(4)
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?
Upgrade checklist
- Run your test suite on 3.15 and look for
isinstance(x, dict)checks that should acceptfrozendict. - Replace
object()placeholders withsentinel()in new code. - Try
-X lazy_imports=allon your CLI and measure startup time before and after. - Check any code that read files without an explicit encoding on Windows.
- Note that the old
profilemodule is deprecated in favour of the newprofilingpackage.
Takeaway
Python 3.15 is a good release for everyday developers. frozendict and sentinel replace common hand-rolled patterns, lazy imports give a real startup win without restructuring code, and comprehension unpacking makes flattening code shorter. Pick one feature and use it in your next project.
Sources: What’s New in Python 3.15 · Python Insider: Python 3.15.0 (final) is here! · Python docs: sentinel
