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@latestPython 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
TrueRuff 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 lazilyBoth 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 typinghas 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.pywhich opens a nice HTML report with a flame graph in your browser:
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.TypedDicts: the boolean closed
argument and extra_items, which allows specifying the type for any additional entries in the
TypedDict.
TypedDicts, as the name suggests, are variants of dicts that associate keys with corresponding
value types for their entries. Before Python 3.15, TypedDicts 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 frozendicts, 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=allflag to do this. You can try this, or thePYTHON_LAZY_IMPORTS=allenvironment variable, on your own code as well. ↩
