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 from object.
  • copy, json, pickle, pprint, marshal, decimal and xml.etree.ElementTree accept 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. Pass repr="<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:

  • lazy is only allowed at module level. Using it inside a function, class or try block is a SyntaxError.
  • 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=0 or pass encoding="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

  1. Run your test suite on 3.15 and look for isinstance(x, dict) checks that should accept frozendict.
  2. Replace object() placeholders with sentinel() in new code.
  3. Try -X lazy_imports=all on your CLI and measure startup time before and after.
  4. Check any code that read files without an explicit encoding on Windows.
  5. Note that the old profile module is deprecated in favour of the new profiling package.

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