Python 3.15: What's New — Lazy Imports, frozendict, Tachyon Profiler, and More
Python 3.15 is a substantial release. Lazy imports cut startup times for large applications,
frozendict and sentinel land as built-ins, comprehensions gain unpacking
syntax, the Tachyon sampling profiler arrives with near-zero overhead, and the JIT compiler
delivers an 8–9% benchmark improvement. This post covers every change worth knowing, with
working code you can try today.
1. PEP 810 — Lazy Imports
The biggest quality-of-life addition for application developers. The lazy keyword
defers module loading until the imported name is actually accessed. For CLIs and web processes
with dozens of top-level imports, this can shave hundreds of milliseconds off cold startup.
lazy import json
lazy import numpy as np
lazy from pathlib import Path
print("Starting up…") # json, numpy, pathlib not loaded yet
data = json.loads('{"key": "value"}') # json loads here, on first use
arr = np.array([1, 2, 3]) # numpy loads here
The same syntax works for from … import statements. The imported name is a
types.LazyImportType proxy — any attribute access or call forces the real
module to load.
lazy from django.db import models # models not imported yet
class Article(models.Model): # models.Model access triggers load
title = models.CharField(max_length=200)
Three other ways to enable lazy imports:
# Command-line flag
python -X lazy_imports myscript.py
# Environment variable
PYTHON_LAZY_IMPORTS=1 python myscript.py
# Programmatically — enable for a block, then restore
import sys
sys.set_lazy_imports(True)
import heavy_module # deferred
import another_big_one # deferred
sys.set_lazy_imports(False)
# Or with a filter — only defer modules matching a predicate
sys.set_lazy_imports_filter(lambda name: name.startswith("myapp."))
Compatibility note: modules that rely on import side-effects (e.g., registering
signals or monkey-patching at import time) will behave differently under lazy imports. Add
__lazy_modules__ = False at module level to opt a specific module out.
2. PEP 814 — frozendict Built-in
frozendict is an immutable, hashable dictionary. It lives in the built-in
namespace alongside dict, frozenset, and friends — no import required.
a = frozendict(x=1, y=2)
b = frozendict({'x': 1, 'y': 2})
print(a) # frozendict({'x': 1, 'y': 2})
print(a['x']) # 1 — reading works normally
a['z'] = 3 # TypeError: 'frozendict' object does not support item assignment
del a['x'] # TypeError: 'frozendict' object does not support item deletion
a.clear() # AttributeError: 'frozendict' object has no attribute 'clear'
Because it's hashable (when all keys and values are hashable), a frozendict
can be used as a dictionary key or as a set member — something plain dict
cannot do:
cache = {}
key = frozendict(user_id=42, locale="en-GB")
cache[key] = "some expensive result"
seen = set()
seen.add(frozendict(x=1, y=2))
# Order doesn't affect equality or hash
frozendict(x=1, y=2) == frozendict(y=2, x=1) # True
hash(frozendict(x=1, y=2)) == hash(frozendict(y=2, x=1)) # True
frozendict is not a subclass of dict — it inherits from
object. This is intentional: it prevents accidental mutation through
dict-typed parameters. Standard library modules (json, pickle,
copy, pprint) all understand frozendict natively.
import json
fd = frozendict(name="Django", version="5.2")
print(json.dumps(fd)) # {"name": "Django", "version": "5.2"}
import copy
print(copy.copy(fd) is fd) # True — immutables are safe to share
print(copy.deepcopy(fd)) # frozendict({'name': 'Django', 'version': '5.2'})
Practical use for Django developers: pass configuration dictionaries as function arguments without worrying about callers mutating them, use frozen dicts as cache keys, or annotate read-only settings objects with a type that enforces immutability at runtime.
3. PEP 661 — sentinel Built-in
Sentinel values — unique objects used as default-argument markers — have historically been
written as bare class instances. Python 3.15 adds a dedicated sentinel()
built-in that produces a properly-behaved sentinel with a clean repr, pickle
support, and type-union compatibility.
# Before Python 3.15 — the old idiom
class _Missing:
def __repr__(self): return ""
MISSING = _Missing()
# Python 3.15
MISSING = sentinel(name="MISSING", repr="<MISSING>")
def get_setting(key, default=MISSING):
if default is MISSING:
raise KeyError(f"No setting: {key!r}")
return default
Sentinels survive copy, deepcopy, and pickle (as long as they're importable at the module
level). They also work with the | union operator in type annotations:
from typing import reveal_type
UNSET = sentinel(name="UNSET")
def fetch(value: int | type(UNSET) = UNSET) -> int | None:
if value is UNSET:
return None
return value
4. PEP 798 — Unpacking in Comprehensions
List, set, and generator comprehensions can now use the * and **
unpacking operators directly — producing a flattened result in a single expression.
lists = [[1, 2], [3, 4], [5]]
# Flatten a list of lists
[*L for L in lists] # [1, 2, 3, 4, 5]
# Merge sets
sets = [{1, 2}, {2, 3}, {3, 4}]
{*s for s in sets} # {1, 2, 3, 4}
# Merge dicts (last key wins)
dicts = [{'a': 1}, {'b': 2}, {'a': 3}]
{**d for d in dicts} # {'a': 3, 'b': 2}
# Generator expression
gen = (*L for L in lists) # generator yielding 1, 2, 3, 4, 5
This replaces common patterns involving itertools.chain.from_iterable() or
nested sum(lists, []) hacks. The generator form is particularly useful for
passing a flattened sequence to a function without materialising the full list:
batches = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
# Before
import itertools
total = sum(itertools.chain.from_iterable(batches))
# Python 3.15
total = sum(*b for b in batches)
5. PEP 686 — UTF-8 as Default Encoding
Python 3.15 makes UTF-8 the default text encoding for I/O operations regardless of the system locale. This closes the long-standing trap where code written on a UTF-8 developer machine silently breaks on a Latin-1 server.
# Python 3.14 and earlier — encoding depended on the OS locale
with open("data.txt") as f: # could be Latin-1 on some systems
content = f.read()
# Python 3.15 — UTF-8 always, regardless of locale
with open("data.txt") as f:
content = f.read()
# Opt back into locale-specific encoding when you need it
with open("legacy.txt", encoding="locale") as f:
content = f.read()
You can also disable it globally with the environment variable
PYTHONUTF8=0 or the flag -X utf8=0.
Impact on Django projects: if your project reads or writes files without
an explicit encoding= argument, Python 3.15 will now use UTF-8 consistently.
Any code that relied on locale-specific encoding silently will need
encoding='locale' added. Run python -W error::EncodingWarning
under Python 3.10+ to find all call sites before upgrading.
6. PEP 799 — The Tachyon Sampling Profiler
Python 3.15 reorganises profiling under a new profiling package and introduces
Tachyon — a statistical sampling profiler capable of up to 1,000,000 Hz
sampling with near-zero overhead. Unlike cProfile, Tachyon doesn't require
modifying or restarting your process.
# Profile a script
python -m profiling.sampling run myscript.py
# Attach to a running process by PID
python -m profiling.sampling attach --pid 1234
# Dump a snapshot from a running process
python -m profiling.sampling dump --pid 1234 --async-aware
# Output as a flamegraph HTML file
python -m profiling.sampling run --format flamegraph -o profile.html myscript.py
Tachyon supports multiple profiling modes and output formats:
- Modes — wall-clock time, CPU time, GIL-holding time, exception-handling time
- Output formats — pstats, collapsed stacks, flamegraph (HTML), gecko, heatmap, live TUI
- Awareness — thread-aware and async-aware (correctly tracks
asynciotasks)
# Programmatic API
from profiling.sampling import Sampler
with Sampler(mode="cpu", rate=10_000) as s:
my_expensive_function()
s.dump("profile.pstats")
The deterministic tracing profiler (cProfile) moves to
profiling.tracing and remains importable as cProfile for
backwards compatibility. The profile module is deprecated and will be removed
in Python 3.17.
PEP 831 accompanies this by enabling frame pointers by default in CPython builds
(-fno-omit-frame-pointer). This makes native stack unwinding faster and more
reliable for external profilers like perf and py-spy.
7. Smarter Error Messages
Python 3.15 extends its already-impressive error message improvements with several new categories of hints.
Inner attribute suggestions
When you access a missing attribute on an object that has a nested object with the attribute you probably wanted:
class Inner:
area = 42
class Container:
def __init__(self):
self.inner = Inner()
c = Container()
c.area
# AttributeError: 'Container' object has no attribute 'area'.
# Did you mean '.inner.area' instead of '.area'?
Cross-language method name hints
Python now recognises common method names from JavaScript, Ruby, and other languages and suggests the Python equivalent:
[1, 2, 3].push(4)
# AttributeError: 'list' object has no attribute 'push'.
# Did you mean '.append'?
'hello'.toUpperCase()
# AttributeError: 'str' object has no attribute 'toUpperCase'.
# Did you mean '.upper'?
{'a': 1}.hasKey('a')
# AttributeError: 'dict' object has no attribute 'hasKey'.
# Did you mean '__contains__' or 'get'?
Mutable/immutable type suggestions
(1, 2, 3).append(4)
# AttributeError: 'tuple' object has no attribute 'append'.
# Did you mean to use a 'list' object?
frozenset({1, 2}).add(3)
# AttributeError: 'frozenset' object has no attribute 'add'.
# Did you mean to use a 'set' object?
delattr() suggestions
class Config:
timeout = 30
c = Config()
del c.timout # typo
# AttributeError: 'Config' object has no attribute 'timout'.
# Did you mean: 'timeout'?
8. Stdlib Highlights
math — new functions
Five new mathematical functions covering common numeric checks:
import math
math.isnormal(1.5) # True — not zero, NaN, inf, or subnormal
math.issubnormal(5e-324) # True — denormalised float
math.fmax(1.0, float('nan')) # 1.0 — ignores NaN (unlike max())
math.fmin(1.0, float('nan')) # 1.0 — ignores NaN
math.signbit(-0.0) # True — checks sign of signed zero
bytearray.take_bytes()
Efficiently drains bytes from a bytearray buffer without an intermediate copy:
buffer = bytearray()
buffer += b"hello world"
# Before Python 3.15 — two operations, one copy
data = bytes(buffer)
buffer.clear()
# Python 3.15 — single atomic operation, no copy
data = buffer.take_bytes()
# buffer is now empty, data holds b"hello world"
# Take only N bytes
data = buffer.take_bytes(5) # takes first 5 bytes
re — prefixmatch() and soft-deprecated match()
re.match() has long confused developers coming from other languages (it only
matches at the start of the string, unlike most regex engines). Python 3.15 adds an explicit
prefixmatch() and soft-deprecates the original name:
import re
# Explicit intent — matches at start of string (prefix)
m = re.prefixmatch(r'\d+', '123abc')
print(m.group()) # '123'
# re.match() still works but is soft-deprecated
# Use re.fullmatch() for full-string matching
# Use re.search() for anywhere-in-string matching
asyncio — TaskGroup.cancel()
You can now cancel all tasks in a TaskGroup early when a goal is achieved —
useful for "first result wins" patterns:
import asyncio
async def search_all(queries):
results = []
async with asyncio.TaskGroup() as tg:
for q in queries:
task = tg.create_task(search_one(q))
task.add_done_callback(
lambda t: tg.cancel() if t.result() else None
)
return results
threading — concurrent iterator utilities
Three new helpers for safely sharing iterators across threads:
import threading
items = iter(range(100))
# Wrap any iterator for thread-safe access
safe = threading.synchronized_iterator(items)
# Distribute items across N worker threads
def worker(it):
for item in it:
process(item)
threads = [threading.Thread(target=worker, args=(safe,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
TOML 1.1 support
The tomllib module (added in 3.11) updates to TOML 1.1, which adds several
quality-of-life improvements:
import tomllib
# TOML 1.1 allows newlines and trailing commas in inline tables
toml_src = b"""
[config]
allowed = {
hosts = ["localhost", "example.com",],
port = 8080,
}
"""
data = tomllib.loads(toml_src.decode())
# New escape sequences: \xHH and \e
toml_src2 = b'path = "C:\\x5cUsers\\x5cjohn"'
data2 = tomllib.loads(toml_src2.decode())
subprocess — efficient process waiting
Popen.wait(timeout=…) now uses platform-native event-driven mechanisms instead
of a busy-poll loop. On Linux 5.3+ it uses pidfd_open() + poll();
on macOS/BSD it uses kqueue; on Windows it uses
WaitForSingleObject. No code changes needed — it's a pure performance
improvement.
unicodedata — Unicode 17.0
The Unicode database updates to version 17.0 and gains several useful new functions:
import unicodedata
unicodedata.block('A') # 'Basic Latin'
unicodedata.isxidstart('A') # True — valid Python identifier start
unicodedata.isxidcontinue('1') # True — valid Python identifier continuation
# Iterate grapheme clusters (user-perceived characters)
text = "é" # 'e' + combining acute accent = 'é' (two code points, one grapheme)
list(unicodedata.iter_graphemes(text)) # ['é']
typing — TypeForm, closed TypedDict, disjoint bases
Three new typing features land in 3.15:
from typing import TypeForm, TypedDict, Any
# PEP 747: TypeForm — annotate type-form values
def cast[T](typ: TypeForm[T], value: Any) -> T: ...
# PEP 728: closed TypedDict — disallow extra keys
class Movie(TypedDict, closed=True):
name: str
year: int
# PEP 728: TypedDict with typed extra items
class FlexMovie(TypedDict, extra_items=str):
name: str
year: int
# Extra keys are allowed, but their values must be str
# PEP 800: disjoint_base — prevents mixed inheritance errors at type-check time
import typing
@typing.disjoint_base
class Backend: ...
class SQLBackend(Backend): ...
class RedisBackend(Backend): ...
# A type checker will flag this as an error:
class BrokenBackend(SQLBackend, RedisBackend): ... # error!
zlib — combining checksums
import zlib
chunk1 = b"hello "
chunk2 = b"world"
crc1 = zlib.crc32(chunk1)
crc2 = zlib.crc32(chunk2)
# Combine without re-processing chunk1
combined = zlib.crc32_combine(crc1, crc2, len(chunk2))
assert combined == zlib.crc32(chunk1 + chunk2)
# Same for Adler-32
adler1 = zlib.adler32(chunk1)
adler2 = zlib.adler32(chunk2)
combined = zlib.adler32_combine(adler1, adler2, len(chunk2))
slice is now generic
slice now supports subscripting, making it usable in type annotations directly:
def apply_slice(data: list[int], s: slice[int, int]) -> list[int]:
return data[s]
os.statx() on Linux
Direct access to Linux's extended statx() syscall for richer file metadata
— creation time, birth time, mount ID, and more — on Linux 4.11+ with glibc 2.28+.
import os
result = os.statx("myfile.txt")
print(result.stx_btime) # birth/creation time (nanosecond precision)
print(result.stx_mnt_id) # mount ID
9. Performance Improvements
JIT compiler: 8–9% benchmark improvement
The experimental JIT compiler introduced in Python 3.13 receives a significant upgrade in 3.15, delivering an 8–9% geometric mean improvement on the pyperformance benchmark suite. Enable it with:
python --enable-experimental-jit myscript.py
# Or build CPython with JIT enabled
./configure --enable-experimental-jit
make -j$(nproc)
Base64 / binary encoding — major speedups
Several encoding modules receive rewrites to C with dramatic performance gains:
- Base64 — encode 2× faster, decode 3× faster
- Ascii85 / Base85 / Z85 — rewritten in C: ~100× faster, ~100× less memory
- Base32 — rewritten in C: ~100× faster
- CSV sniffer —
csv.Sniffer.sniff()up to 1.6× faster
subprocess.Popen.wait() — no more busy polling
As mentioned in the stdlib highlights, Popen.wait(timeout=…) now uses
efficient OS-level event mechanisms. On Linux this replaces a poll loop with
pidfd_open(), reducing CPU usage to effectively zero while waiting.
Frame pointers enabled by default
CPython is now built with -fno-omit-frame-pointer enabled, which allows
native profilers (perf, dtrace, py-spy) to walk
Python stacks more reliably — at a typically negligible runtime cost (~1–3%).
Free-threaded builds: mimalloc
Free-threaded builds (no-GIL, enabled with --disable-gil) now use
mimalloc as the default raw memory allocator, improving allocator performance
under thread contention.
10. Deprecations and Removals
Notable deprecations
profilemodule — deprecated, targeted for removal in Python 3.17. Migrate toprofiling.tracing(or continue usingcProfile).re.match()— soft-deprecated. Usere.prefixmatch()for clarity.unittest.mockbehaviors — several under-specified behaviours formalised with deprecation warnings before change.- Implicit locale-based encoding — opening files without an explicit
encoding=argument will emitEncodingWarningwhenPYTHONWARNDEFAULTENCODING=1is set (preparing for locale encoding removal in a future release).
What was removed
Several APIs that have been deprecated for multiple releases are finally removed in 3.15. The most impactful for typical projects:
sre_*private modules (useredirectly)- Various
astcompatibility aliases that were deprecated since 3.8 - Old-style
datetimeUTC aliases (datetime.utcnow(),datetime.utcfromtimestamp()) — usedatetime.now(timezone.utc) - Several long-deprecated
typingaliases (e.g.,typing.List,typing.Dict) — use the built-inlist,dictdirectly
# Removed in 3.15 — use these instead
from datetime import datetime, timezone
# Wrong (removed)
# now = datetime.utcnow()
# Correct
now = datetime.now(timezone.utc)
# Wrong (removed)
# from typing import List, Dict, Tuple
# def func(items: List[int]) -> Dict[str, Tuple[int, ...]]: ...
# Correct (since Python 3.9)
def func(items: list[int]) -> dict[str, tuple[int, ...]]: ...
11. Upgrading Your Django Project
Python 3.15 is compatible with Django 4.2 LTS and Django 5.x. Here's a practical checklist before upgrading:
Find encoding-unsafe file opens
Run your test suite with the encoding warning enabled to surface any files opened without an explicit encoding:
PYTHONWARNDEFAULTENCODING=1 python -W error::EncodingWarning -m pytest
Replace deprecated typing aliases
# Find old-style typing imports
grep -rn "from typing import.*List\|from typing import.*Dict\|from typing import.*Tuple\|from typing import.*Set" --include="*.py" .
Replace datetime.utcnow()
grep -rn "datetime.utcnow\|utcfromtimestamp" --include="*.py" .
Adopt lazy imports for faster startup
Django management commands and CLI scripts benefit most from lazy imports. A common pattern is to lazy-import heavy optional dependencies at the module level:
# myapp/management/commands/my_command.py
lazy import pandas as pd
lazy import boto3
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def handle(self, *args, **options):
# pandas and boto3 only load if this command actually runs
df = pd.read_csv("data.csv")
Profile before and after
Use Tachyon to capture a flamegraph of your application under 3.14 before upgrading, then compare it under 3.15 to validate the improvement:
# Profile Django startup
python -m profiling.sampling run --format flamegraph -o before.html \
manage.py runserver --noreload
# After upgrading to 3.15, compare
python -m profiling.sampling run --format flamegraph -o after.html \
manage.py runserver --noreload
Summary
Python 3.15 is one of the more developer-facing releases in recent memory. The changes that will have the widest day-to-day impact:
- Lazy imports — the easiest path to faster startup times for existing codebases, requiring no refactoring
- frozendict — fills a genuine gap for immutable dict use cases without needing third-party packages
- UTF-8 default — eliminates an entire category of encoding bugs on non-UTF-8 systems
- Tachyon — a production-grade profiler that attaches to live processes with zero overhead
- Error message improvements — especially the cross-language hints, which are a significant on-boarding improvement
The JIT and encoding performance wins come for free on upgrade. The new language features
(frozendict, lazy imports, comprehension unpacking) are additive — your existing
code keeps working while you adopt them incrementally.