August 22, 2026
Python 3.15 Is Bigger Than You Think: 7 New Features Worth Using in 2026
Python 3.15 is not just another release filled with small standard-library improvements. It introduces new syntax, new built-in types, a…

By Bernd Fischer
8 min read
Python 3.15 is not just another release filled with small standard-library improvements. It introduces new syntax, new built-in types, a serious profiling tool, faster execution through an upgraded JIT, and even changes something as fundamental as Python's default text encoding. If you write Python professionally, several of these features can simplify code you are already writing today.
At the time of writing (August 2026), Python 3.15 is still a prerelease, so this is the time to test rather than blindly upgrade production systems. The feature set has already been frozen, however, making it a good moment to look at what actually matters.
First: Running Python 3.15 with uv
You do not need to replace your system Python to experiment with the release. uv makes it easy to create an isolated project and explicitly select Python 3.15.
uv init python315 --no-package
cd python315
uv python install 3.15
uv python pin 3.15
uv run python --version
uv add --dev ruffuv init python315 --no-package
cd python315
uv python install 3.15
uv python pin 3.15
uv run python --version
uv add --dev ruffIt is also a good idea to manually update pyproject.toml so that the project explicitly declares Python 3.15 as its minimum supported version, especially when using 3.15-specific syntax such as lazy import.
[project]
requires-python = ">=3.15"[project]
requires-python = ">=3.15"This keeps the experiment isolated from your existing Python installations and makes the Python version explicit for the project.
Now let's look at the changes that are actually worth testing.
1. lazy import Finally Makes Lazy Imports a Language Feature
Large Python applications can spend a surprising amount of startup time importing modules they never actually use during that particular execution. Until now, developers often worked around this by moving imports into functions or manually using importlib.
Python 3.15 introduces explicit lazy imports through the new lazy soft keyword. The module is declared normally at module scope, but Python postpones loading it until the imported name is actually used.
import sys
lazy import json
def parse_dataset(raw_data: str) -> list[dict[str, object]]:
"""Parse a JSON dataset into Python objects."""
return json.loads(raw_data)
def main() -> None:
"""Demonstrate when a lazy import is resolved."""
raw_data = '[{"product": "Laptop", "price": 1299.0}]'
print("Before first use:", "json" in sys.modules)
dataset = parse_dataset(raw_data)
print("After first use: ", "json" in sys.modules)
print(dataset)
if __name__ == "__main__":
main()import sys
lazy import json
def parse_dataset(raw_data: str) -> list[dict[str, object]]:
"""Parse a JSON dataset into Python objects."""
return json.loads(raw_data)
def main() -> None:
"""Demonstrate when a lazy import is resolved."""
raw_data = '[{"product": "Laptop", "price": 1299.0}]'
print("Before first use:", "json" in sys.modules)
dataset = parse_dataset(raw_data)
print("After first use: ", "json" in sys.modules)
print(dataset)
if __name__ == "__main__":
main()The example uses sys.modules to verify that json has not been loaded immediately after the lazy import statement. Only when parse_dataset() accesses json.loads() does Python resolve the lazy import and add the module to sys.modules. Although lazy import json appears at the top of the module, json is not actually loaded until parse_dataset() reaches json.loads().
That makes lazy imports particularly interesting for CLIs, large applications, plugin systems, and applications with expensive dependency trees where startup time matters.
Python 3.15 can also enable lazy imports globally. This is useful when you want to test whether lazy imports improve the startup time of an existing application without changing dozens of import statements first. You can enable the behavior for the entire Python process, benchmark the result, and then decide where explicit lazy import statements make sense in production code.
uv run python -X lazy_imports=all main.pyuv run python -X lazy_imports=all main.pyThe important improvement is not merely performance. We can keep imports where readers expect them, at the top of the module, without eagerly paying for every dependency during startup.
2. Python Finally Has frozendict
Python has had immutable tuples and frozenset for years, but no equivalent built-in mapping type.
Python 3.15 finally adds one.
Consider configuration that should never change after construction:
MODEL_CONFIG = frozendict({
"model": "xgboost",
"max_depth": 8,
"learning_rate": 0.05,
})MODEL_CONFIG = frozendict({
"model": "xgboost",
"max_depth": 8,
"learning_rate": 0.05,
})An accidental mutation now fails immediately:
MODEL_CONFIG["max_depth"] = 12
TypeError: 'frozendict' object does not support item assignmentMODEL_CONFIG["max_depth"] = 12
TypeError: 'frozendict' object does not support item assignmentMore importantly, a frozendict is hashable when its keys and values are hashable.
That means this becomes possible:
cache: dict[frozendict, float] = {}
config = frozendict(
model="xgboost",
max_depth=8,
)
cache[config] = 0.934cache: dict[frozendict, float] = {}
config = frozendict(
model="xgboost",
max_depth=8,
)
cache[config] = 0.934For configuration objects, caching keys, immutable metadata, and APIs where mutation would be a bug, this is much cleaner than conventions that merely tell other developers not to modify a normal dictionary.
3. sentinel() Replaces the _MISSING = object() Trick
Experienced Python developers have probably written this more than once:
_MISSING = object()
def update_threshold(value: float | None = _MISSING):
...
_MISSING = object()
def update_threshold(value: float | None = _MISSING):
...
The reason is simple: sometimes None is a perfectly valid value, so you need a third state meaning "the caller supplied nothing."
Python 3.15 standardizes this pattern with the new sentinel built-in.
MISSING = sentinel("MISSING")
def update_threshold(
value: float | None | MISSING = MISSING,
) -> None:
if value is MISSING:
print("Keep existing value")
return
print(f"Set threshold to {value}")MISSING = sentinel("MISSING")
def update_threshold(
value: float | None | MISSING = MISSING,
) -> None:
if value is MISSING:
print("Keep existing value")
return
print(f"Set threshold to {value}")We can now distinguish all three states cleanly:
update_threshold()
update_threshold(None)
update_threshold(0.75)update_threshold()
update_threshold(None)
update_threshold(0.75)Unlike the traditional anonymous object() trick, the new sentinel has a meaningful representation, preserves its identity appropriately, supports pickling under the documented conditions, and can participate directly in type expressions.
This is a small language addition, but it eliminates an ugly pattern from a lot of production Python.
4. Comprehensions Can Finally Unpack
Python comprehensions have always been excellent for transforming data, but flattening nested structures required an additional for clause. Python 3.15 extends comprehension syntax so that iterable and mapping unpacking can happen directly inside list, set, and dictionary comprehensions.
Consider a data pipeline where batches of row IDs need to be combined into a single list. Before Python 3.15, the typical solution required a nested comprehension:
def flatten_batches(batches: list[list[int]]) -> list[int]:
"""Flatten batches into a single list."""
return [
row
for batch in batches
for row in batch
]def flatten_batches(batches: list[list[int]]) -> list[int]:
"""Flatten batches into a single list."""
return [
row
for batch in batches
for row in batch
]Python 3.15 can express the same operation much more directly:
def flatten_batches(batches: list[list[int]]) -> list[int]:
"""Flatten batches into a single list."""
return [*batch for batch in batches]
batches = [
[101, 102, 103],
[104, 105],
[106, 107, 108],
]
rows = flatten_batches(batches)
print(rows)def flatten_batches(batches: list[list[int]]) -> list[int]:
"""Flatten batches into a single list."""
return [*batch for batch in batches]
batches = [
[101, 102, 103],
[104, 105],
[106, 107, 108],
]
rows = flatten_batches(batches)
print(rows)The result is exactly the flattened sequence we expect:
[101, 102, 103, 104, 105, 106, 107, 108][101, 102, 103, 104, 105, 106, 107, 108]The same syntax becomes useful when a pipeline produces multiple sets. In this example, individual processing stages report the columns they require, and we want one unique collection containing all dependencies:
def collect_columns(column_groups: list[set[str]]) -> set[str]:
"""Combine column groups into one unique set."""
return {*columns for columns in column_groups}
column_groups = [
{"customer_id", "revenue"},
{"country", "revenue"},
{"customer_id", "segment"},
]
columns = collect_columns(column_groups)
print(sorted(columns))
def collect_columns(column_groups: list[set[str]]) -> set[str]:
"""Combine column groups into one unique set."""
return {*columns for columns in column_groups}
column_groups = [
{"customer_id", "revenue"},
{"country", "revenue"},
{"customer_id", "segment"},
]
columns = collect_columns(column_groups)
print(sorted(columns))
Because the result is a set, duplicates such as customer_id and revenue disappear automatically:
['country', 'customer_id', 'revenue', 'segment']['country', 'customer_id', 'revenue', 'segment']Dictionary comprehensions gain the corresponding ** unpacking syntax. This is particularly convenient when configuration is assembled from several layers, such as application defaults, environment-specific settings, and runtime overrides:
def merge_config(
config_parts: list[dict[str, object]],
) -> dict[str, object]:
"""Merge configuration layers into one dictionary."""
return {**part for part in config_parts}
config_parts = [
{"host": "db01", "port": 5432},
{"ssl": True, "timeout": 30},
{"timeout": 10},
]
config = merge_config(config_parts)
print(config)def merge_config(
config_parts: list[dict[str, object]],
) -> dict[str, object]:
"""Merge configuration layers into one dictionary."""
return {**part for part in config_parts}
config_parts = [
{"host": "db01", "port": 5432},
{"ssl": True, "timeout": 30},
{"timeout": 10},
]
config = merge_config(config_parts)
print(config)As with regular dictionary unpacking, later values replace earlier values when the same key occurs more than once:
{'host': 'db01', 'port': 5432, 'ssl': True, 'timeout': 10}{'host': 'db01', 'port': 5432, 'ssl': True, 'timeout': 10}The new syntax is more than a cosmetic shortcut. It lets list, set, and dictionary comprehensions describe the actual operation directly: iterate over a collection of containers and unpack each container into the result, without introducing another nested loop merely to express the flattening step.
5. UTF-8 Is Finally the Default
Encoding bugs are among the least interesting bugs you can spend an afternoon debugging.
Python 3.15 makes UTF-8 the default encoding regardless of the operating system environment.
This:
with open("customers.csv") as file:
text = file.read()with open("customers.csv") as file:
text = file.read()now uses UTF-8 when no explicit encoding is supplied.
That reduces platform-dependent behavior, particularly when code moves between Linux, Windows, containers, CI systems, and cloud environments.
You should still be explicit when the file format itself defines an encoding:
with open(
"customers.csv",
encoding="utf-8",
) as file:
text = file.read()with open(
"customers.csv",
encoding="utf-8",
) as file:
text = file.read()Explicit encoding remains excellent documentation. The difference is that accidentally omitting it no longer makes Python's default behavior depend on the surrounding locale.
6. Python Gets a Serious Sampling Profiler
Python 3.15 introduces the new profiling package, including the high-frequency statistical sampling profiler Tachyon under profiling.sampling. Unlike deterministic profilers that instrument individual function calls, a sampling profiler periodically observes the running program, which makes it particularly useful for investigating real applications with relatively low profiling overhead.
Consider a small data-processing workload:
def calculate_scores(size: int) -> int:
"""Calculate scores for a large dataset."""
return sum(value * value for value in range(size))
def main() -> None:
"""Run the processing workload."""
score = calculate_scores(15_000_000)
print(f"Score: {score:,}")
if __name__ == "__main__":
main()
def calculate_scores(size: int) -> int:
"""Calculate scores for a large dataset."""
return sum(value * value for value in range(size))
def main() -> None:
"""Run the processing workload."""
score = calculate_scores(15_000_000)
print(f"Score: {score:,}")
if __name__ == "__main__":
main()
Run it normally with uv:
uv run main.pyuv run main.pyThen profile the same program without adding any profiling code:
uv run python -m profiling.sampling run --mode=cpu main.pyuv run python -m profiling.sampling run --mode=cpu main.pyFor a more useful visual result, Python can generate an interactive flame graph:
uv run python -m profiling.sampling run \
--flamegraph \
-o profile.html \
main.pyuv run python -m profiling.sampling run \
--flamegraph \
-o profile.html \
main.py
The interesting part is that the application itself remains completely unaware of the profiler. Python 3.15 can even attach the sampling profiler to an already-running process by PID, making this much more relevant for diagnosing long-running services and data pipelines than profiling isolated benchmark functions.
7. The JIT Is Starting to Matter
Python's experimental JIT is not new in 3.15, but it has become considerably more capable. The interesting question, however, is not what the JIT does internally but whether we can actually measure a difference in ordinary Python code.
A good test should therefore contain work that happens inside Python rather than NumPy or another native library. The following benchmark repeatedly executes a Python loop containing branching and integer arithmetic:
from time import perf_counter
def calculate_score(size: int) -> int:
"""Calculate a score using pure Python operations."""
total = 0
for value in range(size):
if value % 2 == 0:
total += value * value
return total
def benchmark(runs: int = 10, size: int = 5_000_000) -> None:
"""Benchmark the Python-heavy calculation."""
durations: list[float] = []
for _ in range(runs):
start = perf_counter()
calculate_score(size)
durations.append(perf_counter() - start)
print(f"First run: {durations[0]:.3f} s")
print(f"Fastest run: {min(durations):.3f} s")
print(f"Average: {sum(durations) / len(durations):.3f} s")
if __name__ == "__main__":
benchmark()from time import perf_counter
def calculate_score(size: int) -> int:
"""Calculate a score using pure Python operations."""
total = 0
for value in range(size):
if value % 2 == 0:
total += value * value
return total
def benchmark(runs: int = 10, size: int = 5_000_000) -> None:
"""Benchmark the Python-heavy calculation."""
durations: list[float] = []
for _ in range(runs):
start = perf_counter()
calculate_score(size)
durations.append(perf_counter() - start)
print(f"First run: {durations[0]:.3f} s")
print(f"Fastest run: {min(durations):.3f} s")
print(f"Average: {sum(durations) / len(durations):.3f} s")
if __name__ == "__main__":
benchmark()Now run exactly the same benchmark without the JIT:
uv run python -X jit=0 main.pyuv run python -X jit=0 main.pyThen run it again with the JIT enabled:
uv run python -X jit=1 main.pyuv run python -X jit=1 main.pyYou might see results along these lines:
# JIT disabled
First run: 0.421 s
Fastest run: 0.405 s
Average: 0.411 s# JIT disabled
First run: 0.421 s
Fastest run: 0.405 s
Average: 0.411 sand:
# JIT enabled
First run: 0.419 s
Fastest run: 0.365 s
Average: 0.374 s# JIT enabled
First run: 0.419 s
Fastest run: 0.365 s
Average: 0.374 sDo not expect the JIT to make every workload faster. On my machine, this benchmark was actually slower with JIT enabled because tracing and compilation introduce their own overhead.
That is the important takeaway: Python 3.15's JIT is becoming more capable, but it is still an experimental optimization engine, not a free performance switch.
One More Thing: Better Errors
Python 3.15 continues improving error messages by recognizing cases where the attribute you probably intended exists on a nested object. This is especially useful with wrappers, response objects, and other classes that hold the actual data internally.
Consider this small example:
class Dataset:
"""Store basic dataset metadata."""
def __init__(self, rows: int) -> None:
self.rows = rows
@property
def shape(self) -> tuple[int]:
"""Return the dataset shape."""
return (self.rows,)
class Container:
"""Wrap a dataset instance."""
def __init__(self, inner: Dataset) -> None:
self.inner = inner
def main() -> None:
"""Demonstrate improved attribute suggestions."""
dataset = Dataset(rows=1_000_000)
container = Container(dataset)
print(container.shape)
if __name__ == "__main__":
main()class Dataset:
"""Store basic dataset metadata."""
def __init__(self, rows: int) -> None:
self.rows = rows
@property
def shape(self) -> tuple[int]:
"""Return the dataset shape."""
return (self.rows,)
class Container:
"""Wrap a dataset instance."""
def __init__(self, inner: Dataset) -> None:
self.inner = inner
def main() -> None:
"""Demonstrate improved attribute suggestions."""
dataset = Dataset(rows=1_000_000)
container = Container(dataset)
print(container.shape)
if __name__ == "__main__":
main()Run it with Python 3.15:
uv run main.pyuv run main.pyInstead of merely reporting that Container has no shape attribute, Python 3.15 can recognize that the attribute exists on the nested object and suggest what you probably meant:
AttributeError: 'Container' object has no attribute 'shape'.
Did you mean: 'inner.shape'?AttributeError: 'Container' object has no attribute 'shape'.
Did you mean: 'inner.shape'?The interpreter also recognizes several common method-name mistakes made by developers coming from languages such as JavaScript, Java, Ruby, and C#. It is a small improvement compared with features such as lazy imports or the JIT, but better diagnostics can remove surprisingly large amounts of debugging time.
Should You Switch to Python 3.15 Now?
For production, not yet.
Python 3.15 is still a prerelease at the time of writing, and the official documentation explicitly warns that the What's New document itself remains a draft. But the feature set is frozen, so this is an excellent time to create a test environment and run your existing projects against it.
With uv, doing that is cheap:
uv python install 3.15
uv python pin 3.15
uv sync
uv run pytestuv python install 3.15
uv python pin 3.15
uv sync
uv run pytestThat is probably the most useful Python 3.15 experiment you can run today.
Conclusion
Python 3.15 contains several changes that immediately affect how we write real Python: lazy imports, frozendict, proper sentinels, comprehension unpacking, UTF-8 by default, and significantly better profiling.
But the bigger story may be underneath them. With a rapidly improving JIT, free-threading infrastructure, and better system-level observability, CPython is increasingly attacking limitations that Python developers have simply learned to live with.