Visitar URL original
Python 3.15's best new features - Python Morsels

Python 3.15's best new features

Python 3.15 includes a UTF-8 default encoding, explicit lazy imports, * unpacking in comprehensions, a new sampling profiler, sentinel, frozendict, and more!

Trey Hunner Trey Hunner 8 min read 07:31 video Python 3.15
Watch this as a 07:31 screencast
Same content, narrated.
Watch it

Python 3.15 is now officially released, and I'd like to share some of my favorite new features.

A UTF-8 default encoding

The best feature might be one of the most boring ones.

As of Python 3.15, UTF-8 is the default file encoding on all operating systems, which means... Windows.

Linux and Mac already used UTF-8 as their default file encoding in Python. But on Windows, Python used a legacy code page, which depended on your system's language settings. With English language settings, it was usually CP-1252.

Say a coworker on a Mac writes a file with an accented character in it:

>>> with open("cafe.txt", mode="wt") as f:
...     f.write("café")
...
4

What happens if we read that file from Python running on a Windows machine? Using the Windows default encoding of CP-1252, this is what we'd see:

>>> open("cafe.txt", encoding="cp1252").read()
'café'

We don't get an error... but we do get garbled text. UTF-8 stores é as two bytes, and CP-1252 happily reads those two bytes as two separate characters.

And if the file had an emoji in it, the old default encoding on Windows couldn't even write the file to begin with:

>>> with open("snake.txt", mode="wt", encoding="cp1252") as f:
...     f.write("🐍")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    f.write("🐍")
    ~~~~~~~^^^^^^
  File "/home/trey/.local/share/uv/python/cpython-3.15.0rc2-linux-x86_64-gnu/lib/python3.15/encodings/cp1252.py", line 19, in encode
    return codecs.charmap_encode(input,self.errors,encoding_table)[0]
           ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f40d' in position 0: character maps to <undefined>

In Python 3.15, the open function uses UTF-8 as its default encoding on every operating system, so a file written on one machine reads the same on every other machine.

If your code also needs to run on older versions of Python, it's still a good idea to explicitly pass encoding="utf-8" to the open function.

Sentinel objects

There's also now an official way to invent your own sentinel values in Python.

A sentinel value is a unique value that represents something specific, like None or NotImplemented. A sentinel value is usually treated separately from actual data: its presence is a signal that represents something special.

Up to this point, the most common way to invent your own sentinel value was to just call Python's built-in object class:

>>> MISSING = object()

This makes a completely unique value... but this value doesn't have a name or a friendly string representation:

>>> MISSING
<object object at 0x7efcfed1c8a0>

That's why Python now has a sentinel built-in type:

>>> MISSING = sentinel("MISSING")

The name that we pass to sentinel is used for that sentinel value's string representation:

>>> MISSING
MISSING
>>> type(MISSING)
<class 'sentinel'>

That's handy whenever a function needs a default value that means "nothing was passed in", but None is a value that the caller might actually want to pass in.

So the next time you need to invent your own sentinel value, you can use the new built-in sentinel type.

Unpacking in comprehensions

Have you ever tried to use an asterisk (*) in a list comprehension to flatten a list of lists?

>>> nested = [[1, 2], [3], [4, 5, 6]]
>>> [*sub for sub in nested]
  File "<stdin>", line 1
    [*sub for sub in nested]
     ^^^^
SyntaxError: iterable unpacking cannot be used in comprehension

When you try this on Python 3.14 and below, you'll get a SyntaxError.

Instead of using an asterisk to unpack within a comprehension, you had to flatten your list with a comprehension that has two for clauses:

>>> [x for sub in nested for x in sub]
[1, 2, 3, 4, 5, 6]

In Python 3.15, you can now use an asterisk to unpack within a comprehension:

>>> nested = [[1, 2], [3], [4, 5, 6]]
>>> [*sub for sub in nested]
[1, 2, 3, 4, 5, 6]

This works for list comprehensions, set comprehensions, and generator expressions.

You can also use a double asterisk (**) to unpack dictionaries within a dictionary comprehension:

>>> {**d for d in [{"a": 1}, {"b": 2}]}
{'a': 1, 'b': 2}

Explicit lazy imports

Python 3.15 also has a big syntax addition: explicit lazy imports.

This is a traditional eager import:

>>> import sys

And this is an explicit lazy import:

>>> lazy import json

These lazy imports are called explicit because the importer needs to opt in to them.

We've "imported" the json module, but Python hasn't actually loaded the module yet, so json isn't in the sys.modules dictionary that Python caches module objects in:

>>> "json" in sys.modules
False

The module will only be loaded the first time that we use it:

>>> json.dumps({"a": 1})
'{"a": 1}'

So now it's actually been imported:

>>> "json" in sys.modules
True

The lazy keyword also works with from imports:

>>> lazy from collections import Counter

Lazy imports are primarily for improving a program's startup time. They make the most sense in command-line programs where some modules might go entirely unused.

They don't make a lot of sense in long-running processes, like a web server, which could run for days. In that case, you're not really avoiding the import. You're just delaying it until some future unknown time.

The new profiling package

Python 3.15 also includes a new profiling package, which contains two modules: profiling.tracing and profiling.sampling. The profiling.tracing module is just the new name for the old cProfile module. But profiling.sampling is a new sampling profiler.

Here's a Python script with a couple of slow functions in it:

import time

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

def build_report(n):
    lines = []
    for i in range(n):
        lines.append(f"line {i}: {i * i}")
    return "\n".join(lines)

def main():
    start = time.perf_counter()
    total = 0
    for _ in range(3):
        total += fib(30)
        build_report(200_000)
    print(f"done in {time.perf_counter() - start:.2f}s, total={total}")

if __name__ == "__main__":
    main()

We can profile this Python program by running profiling.sampling from the command line and using its run command to run our program:

$ python3.15 -m profiling.sampling run slow.py
done in 0.26s, total=2496120
Captured 266 samples in 0.27 seconds
Sample rate: 1,000.00 samples/sec
Error rate: 26.32
Profile Stats:
       nsamples   sample%  tottime (ms)    cumul%  cumtime (ms)  filename:lineno(function)
          94/94      48.0        94.000      48.0        94.000  slow.py:11(build_report)
          80/86      40.8        80.000      43.9        86.000  slow.py:6(fib)
            8/8       4.1         8.000       4.1         8.000  slow.py:12(build_report)
          5/110       2.6         5.000      56.1       110.000  slow.py:19(main)
            3/3       1.5         3.000       1.5         3.000  slow.py:3(fib)
...

Summary of Interesting Functions:

Functions with Highest Direct/Cumulative Ratio (Hot Spots):
  1.000 direct/cumulative ratio, 53.6% direct samples: slow.py:(build_report)
  0.935 direct/cumulative ratio, 43.9% direct samples: slow.py:(fib)
  0.026 direct/cumulative ratio, 2.6% direct samples: slow.py:(main)
...

The end of all this output shows a summary of interesting functions, but the beginning of this output shows the raw data, which isn't actually function-based: it's line-based.

A tracing profiler hooks into every function call in our program and records exactly how long each function took to run. A sampling profiler instead sits outside the Python process and peeks at its call stack about a thousand times a second, to see which line of code happened to be running each time it looked.

That might sound slow, but it's actually really fast. This took about a quarter of a second to run, which is about how long it takes to run our code without a profiler:

$ python3.15 slow.py
done in 0.26s, total=2496120

If we run a tracing profiler on our code instead, like Python's built-in tracing profiler (which used to be called cProfile), it takes about six times as long:

$ python3.15 -m profiling.tracing -m slow | head -n 5
done in 1.60s, total=2496120
         8677773 function calls (600164 primitive calls) in 1.604 seconds

   Ordered by: cumulative time

Compared to the very slow tracing profiler, the sampling profiler has basically no overhead at all. And it can even be used on a Python program that's already running.

The attach command accepts the process ID of an already running Python process. Reading another process's memory does require extra permissions though, so on Linux and macOS, you'll need to run that command with sudo:

$ sudo python3.15 -m profiling.sampling attach 1234

The sampling profiler also has other output formats, like flame graphs, and a live mode.

So the next time you need to profile your Python code, definitely check out the new sampling profiler.

Improved error messages

Python 3.15 also includes even more improvements to error messages, and many of them are aimed at folks coming to Python from other programming languages.

For example, if you try to call a push method on a list (which doesn't exist), Python will suggest the append method:

>>> numbers = [1, 2, 3]
>>> numbers.push(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    numbers.push(4)
    ^^^^^^^^^^^^
AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?

More color!

Python 3.15 has even more color as well.

Python 3.13 colorized tracebacks, Python 3.14 colorized the REPL, unittest output, and argparse help text, and Python 3.15 adds color to pretty much the few remaining things that didn't have color yet, like Python's own help output:

$ python3.15 --help

A few quick ones

Before we wrap up, there are a few other new features that I want to show you very quickly.

There's a new frozendict type, which is an immutable, hashable dictionary (I have very occasionally wanted one of these):

>>> defaults = frozendict(verbose=False, retries=3)
>>> defaults["verbose"] = True
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    defaults["verbose"] = True
    ~~~~~~~~^^^^^^^^^^^
TypeError: 'frozendict' object does not support item assignment

If, like me, you find pretty printed output in Python to be not very pretty, you might enjoy a new feature that was added to the pprint function in Python's pprint module:

>>> from pprint import pprint
>>> data = {
...     "name": "Trey",
...     "languages": ["Python", "JavaScript"],
...     "address": {"city": "San Diego", "state": "CA"},
... }
>>> pprint(data)
{'address': {'city': 'San Diego', 'state': 'CA'},
 'languages': ['Python', 'JavaScript'],
 'name': 'Trey'}

In Python 3.15, you can pass expand=True to make your pretty printed output much more readable:

>>> pprint(data, expand=True)
{
 'address': {'city': 'San Diego', 'state': 'CA'},
 'languages': ['Python', 'JavaScript'],
 'name': 'Trey',
}

The re module also has a new prefixmatch function, which does exactly what re.match already does, but with a name that makes a lot more sense:

>>> import re
>>> re.prefixmatch(r"\d+", "123abc")
<re.Match object; span=(0, 3), match='123'>
>>> re.prefixmatch(r"\d+", "abc123")
>>>

Try out Python 3.15 yourself

This is absolutely not everything that's new in Python 3.15. For example, I didn't mention:

  • the opt-in JIT compiler
  • the new math.integer module
  • .pth files versus .start files
  • and lots of other stuff

If you're excited about any of these new features, go install Python 3.15 and start playing with it.

And if you'd like to see everything that's new, go to the "What's New in Python 3.15" page in the documentation.

Test yourself

Quiz yourself on Python 3.15's new features

14 questions · answers revealed as you go · no account needed

Take the quiz

Remember this next month 🧠

Now make sure you remember what you just watched: Daily Recall shows you a few Python questions a day and brings each one back right before you’d forget it.

✕
↑
A Python Tip Every Week

Need to fill-in gaps in your Python skills? I send weekly emails designed to do just that.