This short article walks you through the main things to keep in mind when migrating your code to Python 3.15:
To celebrate the release of Python 3.15, over the next 15 business days I'll be writing about a new 3.15 feature every day. Explained clearly and with examples so you don't have to sift through the changelog.
Subscribe to receive this free email series:
If you want, you can also check the schedule of the upcoming emails.
When a new Python version is released and you're considering of adopting it, you need to think about three disjoint sets of features in the context of your project. The first two are the set of new features that are backwards-compatible with other Python versions and the set of features that are not backwards-compatible. To understand the third set of features that's relevant, take a look at the following screenshot from python.org:

With the release of Python 3.15, Python 3.10 officially reached end-of-life and 3.13 entered the security-only stage. This means that Python 3.10 won't be updated anymore and Python 3.13 won't be improved; it'll only receive security updates. If you're working on projects that need to work across older Python versions, the end-of-life status of 3.10 means you can argue you only need to support 3.11 and newer.1
If a project goes from supporting Python 3.10+ to supporting 3.11+, this means that the project is now free to use whatever features were introduced in 3.11 and were not backwards compatible. This is the third set of features to consider.
Python 3.15 introduced a lot of exciting new features, and there's a lot you can start using already even if you're going to support Python 3.11+ or 3.13+.
Python 3.15's lazy imports introduce new syntax, which automatically means it's not backwards compatible.
However, explicit lazy imports also introduced a backwards-compatible mechanism using a global list __lazy_modules__:
import math
__lazy_modules__ = ["math", "json"]
from json import dump, load
import pathlib
In Python 3.15+, the import of the module json is done lazily because it happens after the module json is included in the list of modules to be loaded lazily.
In earlier Python versions, the list __lazy_modules__ is just a regular Python list and if you don't use it for anything, the list is just ignored and all imports are eager.
Python 3.15 also made some big changes to the typing front, including adding new behaviour to TypedDict.
In most cases, type hints are only used at static type checking time, so there are always to use new typing features in older Python versions.
The most common way in which new typing features are usable in projects that support older Python projects is through the module typing_extensions.
When using typing_extensions not an option, you can also write type hints as strings.
As long as you're using your static type checker with the most recent Python version, all should be fine.
Python 3.15 introduced a new sampling profiler called Tachyon, and while Tachyon can only be used to profile programs running in the same Python version as Tachyon itself, that doesn't mean that a project supporting 3.11+ can't make use of Tachyon.
In practice, for scripts and other programs that run from start to finish, you can try running them directly with Tachyon. The profiling information you get out of Tachyon will only be an approximation of the real numbers if you had to run your code with other Python versions, but it'll still provide insight into bottlenecks.
If your project provides long-running components, like web servers, you can also try running those directly with Python 3.15+ and then using Tachyon to attach to the running process.
If your project publishes C extensions, you can start using the new abi3t already. The abi3t introduces a new limited C API that's compatible with free-threaded builds and using it means you'll be able to keep free-threaded-compatible wheels to a minimum as new Python versions come out.
frozendict
The new built-in frozendict is not backwards-compatible but there's something you should start working on to improve support for frozendict and users of Python 3.15+.
If your project also supports 3.15+, start defaulting to collections.abc.Mapping as the type hint for functions that work with dictionary-like objects and using collections.abc.MutableMapping when the dictionary-like object must be mutable.
You should do this even if your project also supports older Python versions.
That's because dict and frozendict have unrelated class hierarchies, so a function that expects a dict can't be called with a frozendict, although in many places that'd be ok.
Python 3.15 introduces a large number of backwards-incompatible features:
frozendictsentineljson, asyncio, and moreprofiling (that also holds the new profiler Tachyon)The list of features above is far from exhaustive but covers the most notorious backwards-incompatible updates of Python 3.15.
If you're migrating a project to 3.15+, go through the list above to get acquainted with the new features that you have at your disposal. Then, go through your code to look for opportunities to use these new features.
If your code provides any runnable entry points, like CLIs or web servers, consider defaulting to using lazy imports. Lazy imports speed up start up times essentially for free.
frozendict
When thinking about the pair dict vs frozendict, follow this rule:
The default is using
frozendictand usingdictmeans that you're signaling explicitly that the dictionary will have to be mutated.
Don't think about it like this:
The default is using
dictand usingfrozendictmeans that you're signaling explicitly that the dictionary can't change.
It's going to take some effort to fight years of muscle memory, but immutability by default is faster and safer, especially as we get closer to free-threaded Python.
The new built-in frozendict is especially useful when paired with the new parameter object_hook in json.load.
Together, they allow you to load JSON data as an immutable object.
sentinel
To use the new built-in sentinel, look for situations where you need to use a special default value (like None or object) in fairly generic situations where you migth receive any type of Python object.
Use cases for the new built-in sentinel also include scenarios in which you create a custom class to use as a singleton.
Python 3.15 introduces tools to write and work with thread-safe iterators, so if you're writing threaded code, look for places where you could benefit from using plain Python iterators (patched up with the appropriate tools from Python 3.15).
With the transition of Python 3.10 there's a number of major features introduced in 3.11 that are now supported by all active Python releases.
except*
Python 3.11 introduced exception groups and the new syntax except*.
The new exception group syntax using except* was backwards-incompatible, but it's now safe to use it.
An exception group represents a group of unrelated exceptions that you want to raise and propagate together and the syntax except* can be used to match against parts of that exception group.
The snippet below demonstrates the functional behaviour of this features:
def inner():
"""Raises an exception group."""
raise ExceptionGroup(
"inner exception group",
[ValueError(), TypeError()],
)
try:
inner()
except* KeyError:
print("There was a KeyError inside the group")
except* ValueError
print("There was a ValueError inside the group")
# There was a ValueError inside the group
tomllib
Python 3.11 introduced the new module tomllib that adds TOML support to the standard library.
Now, you no longer need a third-party module to parse and read TOML files like the file pyproject.toml that holds Python project configuration.
Although we just discussed above that most typing improvements can be used as soon as they come out, the fact that a typing improvement becomes supported by 100% of active Python versions make it more convenient to use. With that in mind, Python 3.11 introduced a number of convenient typing improvements:
Self for use inside classesLiteralString for increased safety in some types of functionsTypedDict may be marked as required or not requiredAs the next step, go look at your project. If it's something you can play around with freely, upgrade to Python 3.15 and start playing around with the new Python 3.15 features. If not, see if you can increase the lowest supported Python version to 3.11 or 3.13 and start looking for places where the “old new features” can be useful. In either scenario, remember that there's new features in 3.15 that are backwards-compatible!
From the two new built-ins frozendict and sentinel, to lazy imports, package startup configuration files, typing improvements, or more colour everywhere, Python 3.15 has a lot more to offer.
If you want to learn more about what's new in Python 3.15, take a look at the “15 days of Python 3.15” series I'm running.
For 15 days, I'll explain a new Python 3.15 feature every day.
Get the free 3.15 email series:
In practice, I'm sure there's someone somewhere still using Python 2.7, even though that's been EOF for 6 years now... But let's not talk about those people. ↩