Python 3.15

Python 3.15 is out today along with updates for Ruff, uv, and ty.

[email protected] (Brent Westbrook) ۹ دقیقه مطالعهمتن اصلی

Python 3.15, the next stable version of Python, is available now, along with new versions of Ruff, uv, and ty!

To get started, update uv, upgrade to Python 3.15, and install the latest versions of Ruff and ty:

uv self update
uv python upgrade 3.15
uv tool install ruff@latest
uv tool install ty@latest

Python 3.15 includes many exciting new features. We've chosen some of our favorites to describe below, along with related changes in our tools.

Lazy imports #

Python 3.15 introduces a new

lazy

keyword that can be used to defer loading of imports, avoiding the cost of importing modules until they are actually needed. In many cases, lazy imports can replace both function-local and

TYPE_CHECKING

imports that were used to defer expensive imports, allowing you to keep more imports in one block at the top of a file, as recommended by PEP 8. As long as the imports aren't actually used at runtime, they won't be loaded immediately.

You can get a taste for how lazy imports work with a brief REPL session. Regular imports immediately install their modules in

sys.modules

, while lazy imports instead appear only in

sys.lazy_modules

until their first runtime usage.1 Note that the annotation for

x

does not count as a runtime usage because of the deferred annotation semantics introduced in Python 3.14.

>>> import sys
>>> import json
>>> 'json' in sys.modules
True
>>> lazy import tomllib
>>> # Accessing `tomllib` in an annotation does not load the module
>>> x: tomllib.TOMLDecodeError
>>> 'tomllib' in sys.modules
False
>>> 'tomllib' in sys.lazy_modules
True
>>> # Using `tomllib` at runtime finally loads the module
>>> tomllib.loads("tool.ruff.target-version = 'py315'")
{'tool': {'ruff': {'target-version': 'py315'}}}
>>> 'tomllib' in sys.modules
True

Ruff v0.17 stabilizes two new rules to help you manage your lazy imports and updates existing rules to respect import laziness. First, lazy-import-mismatch (TID254) allows you to configure lists of imports that should always be lazy or never be lazy and enforce this in your project. For example, with the following configuration:

[lint.flake8-tidy-imports]
ban-lazy = ["json"]
require-lazy = ["typing"]
TID254

would be emitted on both of these imports:

lazy from json import loads  # TID254: `json` should be imported eagerly
import typing  # TID254: `typing` should be imported lazily

Both diagnostics come with an autofix to apply the correct laziness. For convenience, ban-lazy and require-lazy also recognize a special

all

selector. See their documentation for more details.

Second, lazy-import-immediately-resolved (TID255) helps to identify cases where a nominally lazy import is actually loaded eagerly, defeating the purpose of marking it lazy. For example, using

foo.Bar

as a base class causes the

foo

module to be loaded:

lazy import foo

class C(foo.Bar): ...

It would typically require substantial restructuring to avoid using such an import eagerly, so the rule offers a fix to remove

lazy

from the import to make its immediate use clearer.

For projects that support older Python versions in addition to 3.15, PEP 810 also introduced the

__lazy_modules__

global variable. A definition like the following:

__lazy_modules__ = ["typing"]

import typing

has the same effect as

lazy import typing

but without using new syntax that would be rejected by older Python versions. Ruff recognizes both forms as well.

In addition to these new rules, typing-only-first-party-import (TC001), typing-only-third-party-import (TC002), and typing-only-standard-library-import (TC003) have also been updated both to recognize lazy imports as an alternative to

TYPE_CHECKING

blocks and to recommend using them on Python 3.15 and later.

On the uv side, we've added a preview feature that allows making all build backend imports lazy, speeding up wheel builds by as much as 18% in our benchmarks. Build backends are used to transform source code into a source distribution or wheel for installing or sharing Python packages. This typically involves executing multiple short-lived Python processes, where the overhead of unused runtime imports can be especially noticeable. You can try the new preview feature with

uv build --preview-features build-lazy-imports

, which will make all imports in the build backend lazy, without modifying its source code.2

New profiling tools in the standard library #

The new

profiling

module includes both a deterministic function-call tracing profiler in

profiling.tracing

(relocated from

cProfile

) and a brand-new statistical profiler called Tachyon in

profiling.sampling

. As a statistical profiler and unlike

cProfile

, Tachyon has "virtually zero overhead" even at sampling rates up to one million samples per second. Further, Tachyon can attach to running applications without any code modifications, making it ideal for debugging performance issues, even in a live system.

In lieu of such a live system, the example below uses the RDKit library to count the number of atoms of each element in the first 5,000 molecules from its included sample of the National Cancer Institute dataset:

# element_counts.py

from collections import Counter
from pathlib import Path
from rdkit import Chem, RDConfig

def count_elements(symbols):
    return Counter(sum(symbols, []))

path = Path(RDConfig.RDDataDir) / "NCI/first_5K.smi"
molecules = Chem.SmilesMolSupplier(str(path), delimiter="\t", titleLine=False)
symbols = [
    [atom.GetSymbol() for atom in molecule.GetAtoms()]
    for molecule in molecules if molecule is not None
]
print(count_elements(symbols))

Tachyon can be invoked with uv, including the

rdkit

dependency:

uv run --python=3.15 --with rdkit==2026.3.6 \
  -m profiling.sampling run --flamegraph --browser \
  element_counts.py

which opens a nice HTML report with a flame graph in your browser:

Tachyon flame graph showing count_elements as the main hotspot in the RDKit example

Using either the central flame graph or the hotspots section at the left, we can see that our

count_elements

function accounts for 45.6% of samples. Fortunately, Ruff identifies a performance improvement we can try, using the new comprehension unpacking syntax, via quadratic-list-summation (RUF017):

 def count_elements(symbols):
-    return Counter(sum(symbols, []))
+    return Counter([*sublist for sublist in symbols])

With this fix applied, the script runs in roughly half the time, dropping from ~0.45 seconds to ~0.22 seconds in a quick local benchmark.

New typing features #

Python 3.15 also includes a handful of new typing features. While these are only available in the standard library

typing

module on 3.15 and later, the typing-extensions package makes them available on earlier versions too. Both Ruff and ty treat the versions from

typing-extensions

equivalently to those from

typing

.

closed and extra_items for typing.TypedDict #

PEP 728 introduced two new keyword arguments for

typing.TypedDict

s: the boolean

closed

argument and

extra_items

, which allows specifying the type for any additional entries in the

TypedDict

.

TypedDict

s, as the name suggests, are variants of dicts that associate keys with corresponding value types for their entries. Before Python 3.15,

TypedDict

s were always "open," meaning that they could include keys beyond those in their definitions.

The code in the following example defines a

Point2D

type with two required fields,

x

and

y

. Type-checkers like ty enforce that the right keys are provided in constructors, but because a

Point2D

subtype could also include additional fields, iteration over the values of the dictionary reveals a type of

object

instead of

int

.

from typing import TypedDict, reveal_type

class Point2D(TypedDict):
    x: int
    y: int

def f(point: Point2D):
    for value in point.values():
        reveal_type(value)  # Revealed type: `object`

Often, this is not desirable. By defining your

TypedDict

with

closed=True

instead, you can let the type checker know that dictionaries of type

Point2D

will never contain additional fields. After doing so, the revealed value type above changes to

int

, as you might expect.

Along similar lines, the

extra_items

keyword argument specifies a single type for fields that aren't explicitly listed in the

TypedDict

's definition. For example, we could make

Point2D

into a more general

Point

class that allows any number of additional

int

dimensions, while preserving the same

values

inference shown above:

class Point(TypedDict, extra_items=int): ...

def f(point: Point):
    for value in point.values():
        reveal_type(value)  # Revealed type: `int`

As demonstrated in these examples, ty understands these new keyword arguments, and Ruff does too.

typing.TypeForm #

PEP 747 introduced

typing.TypeForm

, which is a type for type annotations. That might sound a bit arcane if you're not writing many functions that operate on types rather than values, but this is useful for annotating such functions. To use an example from the PEP, consider a

trycast

function, which attempts to cast a

value

to some type, returning

None

if this fails:

from typing import TypeForm

def trycast[T](typx: TypeForm[T], value: object) -> T | None: ...

trycast(str | None, 'hi')

Note that the

typx

argument is a type form, in this case a union of

str

and

None

. As with the new

TypedDict

arguments above, both Ruff and ty have been updated to recognize

TypeForm

and analyze its use accordingly.

New builtin types #

PEP 661 introduced a new

sentinel

type to the standard library, which can be used as a more direct implementation of the common idiom of using

object()

as a sentinel value. Such sentinels are often used as function defaults, where

None

is a useful value to distinguish from providing no value at all. One downside of the old approach is that

object()

produces a rather opaque description when printed:

>>> _sentinel = object()
>>> def f(value=_sentinel): ...
>>> help(f)
Help on function f in module __main__:

f(value=<object object at 0x1089e0830>)

Instead, the new

sentinel

builtin requires a string argument, which specifies the name of the resulting sentinel object. Repeating our example with

sentinel

shows the improved output:

>>> MISSING = sentinel("MISSING")
>>> def f(value=MISSING): ...
>>> help(f)
Help on function f in module __main__:

f(value=MISSING)

As the PEP notes, this has other benefits beyond signature help.

object()

sentinels can also misbehave when being copied or pickled as

is

comparisons will then return

False

. The new

sentinel

handles these cases properly too.

Python 3.15 also includes the new

frozendict

type introduced in PEP 814.

frozendict

is an immutable dict type, much like the analogous set variant,

frozenset

, which was added way back in Python 2.4, when

set

itself was first introduced. PEP 814 wasn't the first time

frozendict

was suggested either, with the previous PEP 416 eventually being rejected without enough compelling use cases. As PEP 814 points out, the intervening fourteen years have provided such compelling uses, including the addition of

asyncio

in Python 3.4 and, more recently, the additions of free-threading in Python 3.13 and

concurrent.interpreters

in Python 3.14. These new ways of easily accessing data concurrently make immutable data structures more important than ever.

Ruff's knowledge of the standard library has been upgraded to include both of these types, as well as the PEP-585-style generic

frozendict

s, such as

frozendict[str, int]

.

Unpacking in comprehensions #

PEP 798 expanded the use of

*

and

**

to allow unpacking of comprehension elements:

>>> [*x for x in [[1, 2, 3], [4, 5, 6]]]
[1, 2, 3, 4, 5, 6]
>>> {**x for x in [{1: 2}, {3: 4}]}
{1: 2, 3: 4}

This is a further generalization of the unpacking introduced in PEP 448, which allowed using

*

and

**

in collection literals. As noted in PEP 798, the new syntax is a more concise alternative to standard-library APIs like

itertools.chain

and

functools.reduce

. In line with this, quadratic-list-summation (RUF017) now recommends the use of the new syntax on Python 3.15 and later, instead of

functools.reduce

and

operator.iadd

.

A performance boost for Windows users #

Last year we described a performance boost for Python interpreters built with Clang 19 and later, which we used for our Python distribution shipped with uv, python-build-standalone. This change has now been incorporated into upstream and python-build-standalone Windows x86-64 builds as well, bringing as much as 15-20% performance improvements on the

pyperformance

benchmark suite, even higher than the 3-5% seen for Linux and macOS platforms.

ARM64 Windows builds #

Windows ARM64 builds of CPython are becoming the default for Windows ARM64 users in both the upstream Python install manager and in uv. Even on ARM64 platforms, x86-64 Python builds were previously the default because released wheels were more common for this architecture and could still be run on ARM systems via emulation. As described in last year's DPO thread, ARM64 has become much more widely supported, to the point that it can be made the default. This also coincides with the promotion of the

aarch64-pc-windows-msvc

target from Tier 3 to Tier 2, where issues with builds must be fixed more quickly and block new releases.

Note that this affects more than just Python 3.15. All versions of Python on ARM64 Windows will now default to ARM64 builds, where available. If you need to revert to the x86-64 builds for whatever reason, you can use the

UV_PYTHON_ARCH=x86_64

environment variable or explicitly request a specific architecture as part of your Python version, as in

uv python install cpython-3.15-windows-x86_64

.

Python 3.10 is reaching end of life #

Python 3.10 was first released in October 2021, and is reaching the end of its five-year support window. As a result, Ruff v0.17 increases the default Python version from 3.10 to 3.11. This default value only applies if you don't have a more specific target-version selected in your Ruff configuration or

requires-python

in your

pyproject.toml

. Similarly, this change doesn't affect Ruff's minimum supported Python version, which remains Python 3.7. uv will also continue to support Python 3.10, but no new builds will be published after its final release, in line with the end of upstream security support.

Support for ruff-lsp has been removed #

Ruff v0.17 removes support for ruff-lsp, our Python-based language server that was deprecated in Ruff v0.9.5. The Ruff VS Code extension now always uses the native language server, and the

ruff.nativeServer

setting is deprecated and ignored. If you’re still using ruff-lsp or legacy settings such as

ruff.lint.args

or

ruff.format.args

, see the migration guide. If something prevents you from switching to the native server, please open an issue to help us understand your use case and what’s blocking your migration.

What else is changing? #

If you want to know more, you can find an extensive list of the changes to Python in the "What's new in Python 3.15" page. You can also check out the full changelogs for Ruff, uv, and ty on GitHub.

Read more about Astral — the company behind these tools.

Thanks to Dhruv Manilawala, David Peter, Micha Reiser, and Alex Waygood who contributed to this blog post.

Footnotes #

  • As I found out while writing this, the REPL's tab-completion can force imports to be resolved! So be sure to type out the example in full, if you're following along. ↩

  • uv uses CPython's new

    -X lazy_imports=all flag to do this. You can try this, or the PYTHON_LAZY_IMPORTS=all environment variable, on your own code as well. ↩

A

منتشرشده در

Astral

خواندن در منبع

ادامه مطالعه