Python 3.15 ships on October 1, and that's one of five changes that reach the code you write every day. In July I wrote up the 3.14 features that changed how I write code and ended with two things I was still waiting for. So I installed the release candidate with uv python install 3.15.0rc2, ran every example below on it, and kept the numbers.

Eager vs lazy imports: what runs before your code does

1. lazy import makes slow CLI startup a solved problem

Put lazy in front of a module-level import and Python binds the name immediately but doesn't load the module until the first time you touch it. That's PEP 810. It's the one feature I'd upgrade for on its own.

Here's the shape of half the CLI tools I maintain. Fifteen stdlib imports at the top, and a main() that on most runs touches one of them:

# cli_tool.py
      import argparse
      import asyncio
      import http.client
      import ssl
      import sqlite3
      import xml.etree.ElementTree
      import tomllib
      import zipfile
      # ... 7 more

      def main():
          args = argparse.ArgumentParser().parse_args()
          print("ready")

      if __name__ == "__main__":
          main()
      

Prefix every import with lazy and nothing else changes:

lazy import argparse
      lazy import asyncio
      lazy import sqlite3
      # ... same for the rest
      

Ten runs of each on 3.15.0rc2, median wall time:

script modules loaded by the time main() returns startup
eager imports 185 89 ms
lazy imports 37 (all of them argparse, the one module main() uses) 26 ms
bare python -c pass 0 13 ms

The eager version spends about 63 ms loading fourteen modules the run never touches. The lazy version still pays for argparse, because main() calls it, and pays for sqlite3 only on the run that calls sqlite3.connect(). You can watch it happen:

import sys
      lazy import sqlite3
      print("sqlite3" in sys.modules)   # False
      sqlite3.connect(":memory:")
      print("sqlite3" in sys.modules)   # True
      

The rules are strict. The errors are good, though. lazy works at module scope only. Inside a function, a class body, or a try block it's a SyntaxError. So are lazy from x import * and lazy from __future__ import. If a lazily imported module doesn't exist, the ImportError surfaces at first use, and the traceback shows both the import line and the line that triggered it.

Two switches matter for real projects. python -X lazy_imports=all (or PYTHON_LAZY_IMPORTS=all) makes every module-level import lazy without touching source, and gave me the same 26 ms. For a library that must still import on 3.14, declare __lazy_modules__ = ["json", "pathlib"] at the top of the module. Older Pythons ignore the name and import eagerly.

One warning: keep imports with side effects eager. Anything that registers a plugin, configures logging, or patches a module on import must not be deferred, or it silently never runs.

2. Unpacking works inside comprehensions

[*sub for sub in lists] flattens a list of lists, and it's the fastest way to do it. PEP 798 allows * and ** inside list, set, and dict comprehensions, which turns the double-for idiom into one line:

lists = [[1, 2], [3, 4], [5]]

      [*sub for sub in lists]                  # [1, 2, 3, 4, 5]  (new)
      [x for sub in lists for x in sub]        # the 3.14 way

      dicts = [{"a": 1}, {"b": 2}, {"a": 3}]
      {**d for d in dicts}                     # {'a': 3, 'b': 2}, last write wins
      

Flattening 1,000 lists of 20 ints with timeit, best of five runs:

idiom time per flatten
[x for sub in lists for x in sub] 201 µs
list(itertools.chain.from_iterable(lists)) 169 µs
[*sub for sub in lists] 73 µs

It's faster because the interpreter extends the result list once per inner list instead of appending one element at a time. On 3.12 and 3.14 the same line is SyntaxError: iterable unpacking cannot be used in comprehension, so this is a 3.15-only idiom, not a backport candidate.

3. frozendict is a built-in

frozendict is an immutable, hashable mapping you can use as a dict key, a cache key, or a config object nobody can mutate by accident. It's PEP 814 and needs no import:

>>> cfg = frozendict(host="db", port=5432)
      >>> cfg["port"] = 1
      TypeError: 'frozendict' object does not support item assignment
      >>> pools = {cfg: "pool-1"}
      >>> pools[frozendict(port=5432, host="db")]        # order doesn't matter
      'pool-1'
      >>> cfg | {"port": 6543}
      frozendict({'host': 'db', 'port': 6543})
      >>> hash(frozendict(a=[1]))                        # values must be hashable too
      TypeError: unhashable type: 'list'
      

The one thing that will bite you: frozendict isn't a subclass of dict. isinstance(cfg, dict) is False. Check against collections.abc.Mapping instead, which also covers MappingProxyType. The stdlib already accepts it where you'd expect, including json.dumps, copy, pickle, and pprint.

4. A profiler that attaches to a live process

python -m profiling.sampling attach <PID> profiles a running Python process from the outside, with no code change, no restart, and no slowdown in the target. The tool is called Tachyon (PEP 799).

Tachyon reads a live process from outside it

It's a separate process that reads the target's memory and rebuilds the stack, so the target pays nothing. Four subcommands, plus a --live flag for a top-style view:

python -m profiling.sampling run hot.py            # run a script under the profiler
      python -m profiling.sampling attach 4242           # attach to a live PID
      python -m profiling.sampling attach --live 4242    # top-style TUI on a live PID
      python -m profiling.sampling dump 4242             # one stack snapshot, no profiling
      python -m profiling.sampling replay profile.bin    # convert a saved profile
      

Two seconds against a script with two hot loops:

Captured 2,001 samples in 2.00 seconds
      Sample rate: 1,000.45 samples/sec

        nsamples  sample%  tottime (ms)  cumul%  cumtime (s)  filename:lineno(function)
         943/943     47.1       943.000    47.1        0.943  hot.py:5(hash_loop)
         780/780     39.0       780.000    39.0        0.780  hot.py:11(parse_loop)
      

Add --flamegraph for a self-contained HTML flame graph, --gecko for the Firefox Profiler, --mode gil to see who holds the GIL, or --async-aware for asyncio code. The docs quote rates up to 1,000,000 samples per second; the default 1,000 nailed the split above.

One Linux gotcha: attach reads another process's memory, so a stock Ubuntu (kernel.yama.ptrace_scope=1) refuses it even for your own processes. The error message tells you the fixes: sudo -E, echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope, or --cap-add=SYS_PTRACE in Docker. run mode needs none of that. The old deterministic profiler now lives at profiling.tracing, and cProfile still imports.

5. sentinel kills the object() trick

MISSING = sentinel("MISSING") gives you a unique "no argument was passed" marker with a readable repr, and it's a built-in (PEP 661). Every codebase I've touched reinvents this with a bare object():

>>> import pickle
      >>> MISSING = sentinel("MISSING")
      >>> def fetch(key, default=MISSING):
      ...     if default is MISSING:
      ...         raise KeyError(key)
      ...     return default
      >>> MISSING
      MISSING
      >>> int | MISSING                                  # valid in a type annotation
      int | MISSING
      >>> pickle.loads(pickle.dumps(MISSING)) is MISSING
      True
      

Identity survives pickling and copying, which the object() trick never did, and the repr says MISSING instead of <object object at 0x7f...>.

The smaller wins I already use

  • open("notes.txt") is UTF-8 on every platform now (PEP 686), so the "written on Linux, read on Windows as cp1252" bug is gone. PYTHONUTF8=0 if a legacy pipeline needs the old behaviour.
  • asyncio.TaskGroup.cancel() ends a group early without try/except boilerplate. I use it for "take the first result, drop the rest".
  • os.makedirs(path, parent_mode=0o755) and Path.mkdir(parent_mode=...) set a separate mode for the intermediate directories.
  • json.loads(text, array_hook=tuple) turns JSON arrays into whatever you want, the way object_hook always did for objects.
  • Error messages now speak other languages: [1, 2].push(3) says Did you mean '.append'?, and (1, 2).append(3) asks whether you wanted a list.
  • argparse --help, timeit, ast, sqlite3, and http.server output got colour, and difflib.unified_diff() grew a color=True parameter for git-style diffs.

What I'm still waiting for

Both wishes from the 3.14 post are closer, and neither has arrived.

The JIT on by default. The 3.15 JIT is a real upgrade. The What's New page reports pyperformance results of roughly 8 to 9 percent geometric-mean speedup on x86-64 Linux over the standard interpreter. On AArch64 macOS it's 12 to 13 percent over the already faster tail-calling interpreter. The page flags both numbers as not final. It still ships off. The official Windows and macOS binaries build it in, but you opt in with PYTHON_JIT=1. PEP 836, still a draft, proposes the bar the JIT would have to clear to stop being experimental. I want the release where I forget the environment variable exists.

Free-threading as the plain python. The no-GIL build is still a separate build in 3.15. What landed is the plumbing: PEP 803 defines a stable ABI for free-threaded builds (abi3t), so extension authors can ship one wheel that works there. The What's New page also notes that setuptools, meson-python, scikit-build-core, and Maturin don't support abi3t yet. When they do, the wheel ecosystem can catch up, and then the default build can change. Not this year.

What to do this week

Install the release candidate (uv python install 3.15.0rc2) and run your test suite against it. When it passes, make three changes in this order. Add lazy to the heavy imports in every CLI entry point and measure the startup. Replace your flatten idioms with [*sub for sub in lists]. Switch your immutable config objects to frozendict. Then, the next time a process is slow in production, attach Tachyon to it before you touch the code.


I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].

Get the next one in your inbox → subscribe at astraedus.dev.