Python 3.11: What's New

Last updated November 7th, 2022

Python 3.11 was released on October 24th, 2021. This article looks at the most interesting new additions to the Python language that will help to make your Python code even cleaner:

  1. Faster CPython
  2. Improved type hints
  3. Better error messages
  4. Exception notes
  5. TOML library

Contents

Installing Python 3.11

If you have Docker, you can quickly spin up a Python 3.11 shell to play around with the examples in this article like so:

$ docker run -it --rm python:3.11

Not using Docker? We recommend installing Python 3.11 with pyenv:

$ pyenv install 3.11.0

You can learn more about managing Python with pyenv from the Modern Python Environments - dependency and workspace management article.

Faster CPython

Python 3.11 is faster than ever! As stated in the release notes, Python 3.11 is 10 - 60% faster than Python 3.10. On average, it's 25% faster. Python's core developers did a great job improving execution time in a number of areas.

Faster Startup

In terms of startup, rather than Reading __pycache__ -> Unmarshall -> Heap allocated code object -> Evaluate, Python 3.11 uses a new process. Core modules essential for the startup of the Python interpreter are now frozen in the interpreter -- their code is statically allocated. The new flow is Statically allocated code object -> Evaluate. The latter is 10 - 15% faster.

Faster Runtime

Every time you call a function in Python, a frame gets created. It holds information about function execution. The core developers simplified their creation process, internal information, and memory allocation. Another improvement occurred with inline function calls. From now on, most of the Python function calls consume no C stack space.

Maybe the most important improvement in this regard is PEP 659: Specializing Adaptive Interpreter. This PEP is one of the key elements for fast Python. Its main idea is that Python code has regions where types rarely change. Within those regions, Python can optimize operations by using more specialized types when it sees that some operation is always done only for certain types of data. For example, instead of using general multiplication, it can use multiplication for integers if only integers are being used. Another example is the direct call of underlying C implementations for common built-in functions like len and str instead of going through the internal calling convention.

You can observe this by checking generated bytecode:

import dis
from random import random


def dollars_to_pounds(dollars):
    return 0.87 * dollars


dis.dis(dollars_to_pounds, adaptive=True)
#   5           0 RESUME                   0
#
#   6           2 LOAD_CONST               1 (0.87)
#               4 LOAD_FAST                0 (dollars)
#               6 BINARY_OP                5 (*)
#              10 RETURN_VALUE

for _ in range(8):
    dollars_to_pounds(random() * 100)


dis.dis(dollars_to_pounds, adaptive=True)
#   5           0 RESUME_QUICK             0
#
#   6           2 LOAD_CONST__LOAD_FAST     1 (0.87)
#               4 LOAD_FAST                0 (dollars)
#               6 BINARY_OP_MULTIPLY_FLOAT     5 (*)  # <--  CHANGED!
#              10 RETURN_VALUE

As you can see, after 8 calls of dollars_to_pounds with a float, the bytcode is optimized. Instead of using BINARY_OP, it's using specialized BINARY_OP_MULTIPLY_FLOAT, which is faster when multiplying floats. If you'd run the same thing with an older Python version there wouldn't be any difference.

For more, refer to the official docs along with the associated PEP 659 - Specializing Adaptive Interpreter.

Improved Type Hints

As in the last couple of releases, there are improvements on the type hinting front.

Self Type

Python finally has support for a Self type. So, now you can easily type class methods and dunder methods:

from typing import Self
from dataclasses import dataclass


@dataclass
class Car:
    manufacture: str
    model: str

    @classmethod
    def from_dict(cls, car_data: dict[str, str]) -> Self:
        return cls(manufacture=car_data["manufacture"], model=car_data["model"])


print(Car.from_dict({"manufacture": "Alfa Romeo", "model": "Stelvio"}))
# Car(manufacture='Alfa Romeo', model='Stelvio')

More info:

  1. Official docs
  2. PEP-673

TypedDict NotRequired

Another improvement is the NotRequired type for typed dictionaries:

from typing import TypedDict, NotRequired


class Car(TypedDict):
    manufacture: str
    model: NotRequired[str]


car1: Car = {"manufacture": "Alfa Romeo", "model": "Stelvio"}  # OK
car2: Car = {"manufacture": "Alfa Romeo"}  # model (model is not required)
car3: Car = {"model": "Stelvio"}  # ERROR (missing required field manufacture)

More info:

  1. Official docs
  2. PEP-655

Literal String Type

There's also a new LiteralString type that allows literal strings and strings created from other literal strings. This can be used for type checking when executing SQL and shell commands to add another layer of safety for preventing injection attacks.

Example:

def run_query(sql: LiteralString) -> ...
    ...

def caller(
    arbitrary_string: str,
    query_string: LiteralString,
    table_name: LiteralString,
) -> None:
    run_query("SELECT * FROM students")       # ok
    run_query(query_string)                   # ok
    run_query("SELECT * FROM " + table_name)  # ok
    run_query(arbitrary_string)               # type checker error
    run_query(                                # type checker error
        f"SELECT * FROM students WHERE name = {arbitrary_string}"
    )

More info:

  1. Official docs
  2. PEP-675

--

There are also some other improvements related to type hints. You can read about them in the official release notes.

Better Error Messages

One of the exciting new features is more descriptive tracebacks. Python 3.10 saw some improvements made by introducing better and more descriptive errors. Python 3.11 takes it a step further with improved descriptions of the exact error position. Let's take a look at an example.

Code with syntax error:

def average_grade(grades):
    return sum(grades) / len(grades)


average_grade([])

Error in Python 3.10:

    return sum(grades) / len(grades)
ZeroDivisionError: division by zero

Error in Python 3.11:

    return sum(grades) / len(grades)
           ~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero

How about something a bit more complex?

Python 3.10:

def send_email_to_contact(contact, subject, content):
    print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")


contact = {
    "first_name": "Lightning",
    "last_name": "McQueen",
    "emails": [
        {
            "address": "[email protected]",
            "display_name": "Lightning McQueen"
        }
    ]
}

send_email_to_contact(contact, "Hello", "Hello there! Long time no see.")
#    print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# TypeError: list indices must be integers or slices, not str


send_email_to_contact({}, "Hello", "Hello there! Long time no see.")
#    print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# KeyError: 'emails'


send_email_to_contact({"emails": {"address": "[email protected]"}}, None, "Hello there! Long time no see.")
#     print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
# AttributeError: 'NoneType' object has no attribute 'title'

Python 3.11:

def send_email_to_contact(contact, subject, content):
    print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")


contact = {
    "first_name": "Lightning",
    "last_name": "McQueen",
    "emails": [
        {
            "address": "[email protected]",
            "display_name": "Lightning McQueen"
        }
    ]
}

send_email_to_contact(contact, "Hello", "Hello there! Long time no see.")
#     print(f"Sending email to {contact['emails']['address']} with {subject=} and {content=}")
#                               ~~~~~~~~~~~~~~~~~^^^^^^^^^^^
# TypeError: list indices must be integers or slices, not str


send_email_to_contact({}, "Hello", "Hello there! Long time no see.")
#     print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
#                               ~~~~~~~^^^^^^^^^^
# KeyError: 'emails'


send_email_to_contact({"emails": {"address": "[email protected]"}}, None, "Hello there! Long time no see.")
#     print(f"Sending email to {contact['emails']['address']} with subject={subject.title()} and {content=}")
#                                                                           ^^^^^^^^^^^^^
# AttributeError: 'NoneType' object has no attribute 'title'

As you can see, it's much easier to find out where the error is from the new traceback. The exact problematic spot in the line with exception is well-marked. In older versions, you can see only the exception itself and the line where it was raised.

More info:

  1. Official docs
  2. PEP-657

Exception Notes

add_note was added to BaseExceptions. This allows you to add additional context to an exception after it was created. For example:

try:
    raise ValueError()
except ValueError as exc:
    exc.add_note("When this happened my dog was barking and my kids were sleeping.")
    raise

#     raise ValueError()
# ValueError
# When this happened my dog was barking and my kids were sleeping.

More info:

  1. Official docs
  2. PEP-678

TOML Library

Python now has a library for parsing TOML files called tomllib. Its usage is very similar to that of the built-in json library.

For example, say you have the following pyproject.toml file:

[tool.poetry]
name = "example"
version = "0.1.0"
description = ""
authors = []

[tool.poetry.dependencies]
python = "^3.11"


[tool.poetry.dev-dependencies]

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

You can load the file like so:

import pprint
import tomllib

with open("pyproject.toml", "rb") as f:
    data = tomllib.load(f)

pp = pprint.PrettyPrinter(depth=4)
pp.pprint(data)
"""
{'build-system': {'build-backend': 'poetry.core.masonry.api',
                  'requires': ['poetry-core>=1.0.0']},
 'tool': {'poetry': {'authors': [],
                     'dependencies': {'python': '^3.11'},
                     'description': '',
                     'dev-dependencies': {},
                     'name': 'example',
                     'version': '0.1.0'}}}
"""

More info:

  1. Official docs
  2. PEP-680

Conclusion

As you can see, Python 3.11 brought many exciting improvements and features. On the less exciting side, some legacy modules were deprecated and others removed altogether. Since this article covered only the most interesting new features and changes, be sure to check out the official release notes for all the changes.

Happy Pythoning!

Jan Giacomelli

Jan Giacomelli

Jan is a software engineer who lives in Ljubljana, Slovenia, Europe. He is a Staff Software Engineer at ren.co where he is leading backend engineering efforts. He loves Python, FastAPI, and Test-Driven Development. When he's not writing code, deploying to AWS, or speaking at a conference, he's probably skiing, windsurfing, or playing guitar. Currently, he's working on his new course Complete Python Testing Guide.

Share this tutorial

Featured Course

Scalable FastAPI Applications on AWS

In this course, you'll learn how to go from idea to scalable FastAPI application running on AWS infrastructure managed by Terraform.

Featured Course

Scalable FastAPI Applications on AWS

In this course, you'll learn how to go from idea to scalable FastAPI application running on AWS infrastructure managed by Terraform.