#python

Python 3.9-3.11 new features


On October 2020, Python 3.9 came out. A year later, 3.10 followed, and following the same trend, in October 2022 we were blessed with Python 3.11. While it's great news that the language gets frequent and constant updates, it's sometimes hard to keep track of all the new features introduced and adopt them in your day to day coding. As a result, you might be writing Python code that is clearly unaware of what the language has been equipped with in the last few years. Even if as a developer you aren't particularly keen on using the latest code quirks and tricks yourself, other developers won't feel the same, so you still have to at least know what a certain piece of code does when you stumble upon some unfamiliar syntax.

In this blog post, I have compiled the most useful/important changes introduced by Python 3.9-3.11. This list is by no means extensive; it's only what I've personally adopted from these releases. It will also only consist of changes related to writing/reading code. I will not mention the quite impressive startup and execution speed enhancements, the very helpful improved error messages, security improvements and so on.

 


Dict operations

 

Prior to these releases, when we wanted to merge two or more dicts, we would usually unpack them inside a new dict literal:


dict1 = {"a": 1, "b": 2}
dict2 = {"c": 3, "d": 4}

merged_dict = {**dict1, **dict2}

Now we can use the pipe operator to achieve the same:

merged_dict = dict1 | dict2
print(merged_dict)  # {"a": 1, "b": 2, "c": 3, "d": 4}

For updating a dict with key-value pairs from another dict, we'd usually go with the update() method:

dict1 = {"a": 1, "b": 2, "c": 300}
dict2 = {"c": 3, "d": 4}

dict1.update(dict2)

Now we can use |= to achieve the same:

dict1 |= dict2

print(dict1)  # {'a': 1, 'b': 2, 'c': 3, 'd': 4}

This is just some syntactic sugar to provide consistency with set operation syntax introduced in earlier versions. I do think the merge syntax is neat and convenient, but for the update operation, I think using update() might be more readable and intuitive.

 

Improved type hinting


Typing has been improving with every release, and with new frameworks and libraries incorporating it, it's clear that we're heading towards type hinting becoming ubiquitous in the near future. Some of these recent improvements include support for more specific types, such as Literal, which allows you to specify a specific value or values, and Annotated, which allows you to add further annotations to a name.

from typing import Literal, Annotated

def get_gender(gender: Literal["male", "female"]) -> Annotated[str, "this is the gender of the person"]:
    return gender

 

You should use the pipe operator to replace typing.Union, as it's more readable and you don't need to import anything.

    
def my_function(my_var: int | str) -> None:
    print(my_var)

It also works for real type checking:

isinstance(my_var, int | float)
    
You can also now use collection types directly as annotations, instead of having to import  List, Dict, Tuple, Set from typing. Other than avoiding the extra import statement, the code is also more readable and less confusing now. For example, you can write
my_list: list[int] = [1, 2, 3]
instead of
my_list: typing.List[int] = []

You can also write

my_list = list[int]()


This works in the same way for tuples, sets and dicts as well:
d: dict[str, int] = {"answer": 42}
s: set[int] = {1, 2, 3}
t: tuple[int, int, int] = (1, 2, 3)

Another helpful addition is typing.Self, to annotate that a method is returning an instance of its class:
class A:
     def a(self) -> typing.Self:
         # do something
         return self

         
Prior to this, we had to use a TypeVar (and define it first), which was more verbose and not that intuitive. Or import annotations from __future__, which would allow us to use the class name directly within the class body, despite the class itself not having been defined yet. But that would lead to issues with inheritance, because when a child class would call the method that returns an instance, an instance of Child, not Parent, would be returned. But we had statically annotated the return type as Parent, which is wrong. Also, importing from __future__ always bears some risk. typing.Self is a very practical solution to all of this.

    

Structural pattern matching


With the newly introduced match keyword, you can finally write a switch block in Python. I think it makes the code simpler and easier to read, as well as more explicit (pleasing the Zen), and for that reason I find myself using it to replace even simple (unchained) if-else blocks.
    
data = {'foo': 'bar'}

match data:
    case {'foo': 'bar'}:
        print('foo is bar')
    case {'foo': 'baz'}:
        print('foo is baz')
    case _:
        print('foo is not bar or baz')

        

As seen above, you can not only match single values, but also entire collections. You can also match two values in the same case, when you want to handle them the same way:


status_code = 200
match status_code:
     case 401 | 403:
         print("Not allowed")
     case _:
         print("Smth else")

    
You can also match the same value twice, with a guard:


my flag: bool = True
match status_code:
     case 401 if my_flag:
         print("Not allowed")
     case 401:
         print("Not allowed, flag is off")
     case _:
         print("Smth else")

        
        
Parenthesized context managers


When you're nesting multiple context managers, you are no longer forced to write them in the same line and use the ugly line continuation character if it gets too long. Now you can simply enclose everything in parentheses and have it formatted just like any other piece of Python code would've, which makes for much better readability.

with (

    open("input.txt") as input_file,

    open("output.txt", "w") as output_file

):
    # do something

    

Strict zipping

 

Now you can pass a strict argument to zip(), and when it's set to True, an error will be raised if the lengths of the two iterables don't match. This is quite useful in saving us from writing the same boilerplate code to check the lengths ourselves.


zip(a_iterable, b_iterable, strict=True)


Adding notes to exceptions

 

Now you can add notes to an exception before raising it again. This is something that I prefer a lot, as the same type of exception can be raised in totally different contexts throughout the codebase, and by adding further notes to it, you can make it more clear what exactly went wrong, or state common/known issues in that specific part of the code. Put simply, notes allow you to encapsulate local context within the exception, which makes for a better developer experience when coming across such exception. You can add multiple notes, and they will all appear at the bottom of the stack trace.


try:
    int("Hello")
except ValueError as e:
     e.add_note("My note")
     raise

    
    

String enums


We often have to define enums whose values are strings, and the value matches the name. In such cases, you can now use auto() to auto-generate the value from the name, as opposed to having to assign to every name a corresponding str literal:


from enum import StrEmum, auto

class DayOfTheWeek(StrEnum):
     Monday = auto()  # value is "monday"
     Tuesday = auto()

     # other days of the week

 

 

Conclusion

 

To wrap it up, these were some of the most important changes introduced in Python 3.9, 3.10 and 3.11, that I find myself using a lot. As mentioned at the start, I have not included improvements that I cannot showcase with simple code examples. Additionally, I may have forgotten to include some new feature or syntax, or may very well not be aware of it at all (let me know if that's the case). I've also probably omitted some of the typing changes; in my defense, there's been a lot of them. But I think this post should serve as a good starting point for anyone trying to get up to date with recent developments in Python.