Python Performance New Features

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 asyncio tasks)
# 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 sniffercsv.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

  • profile module — deprecated, targeted for removal in Python 3.17. Migrate to profiling.tracing (or continue using cProfile).
  • re.match() — soft-deprecated. Use re.prefixmatch() for clarity.
  • unittest.mock behaviors — several under-specified behaviours formalised with deprecation warnings before change.
  • Implicit locale-based encoding — opening files without an explicit encoding= argument will emit EncodingWarning when PYTHONWARNDEFAULTENCODING=1 is 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 (use re directly)
  • Various ast compatibility aliases that were deprecated since 3.8
  • Old-style datetime UTC aliases (datetime.utcnow(), datetime.utcfromtimestamp()) — use datetime.now(timezone.utc)
  • Several long-deprecated typing aliases (e.g., typing.List, typing.Dict) — use the built-in list, dict directly
# 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.