Python 3.10 is out! Volunteers have been working on the new version since May 2020 to bring you a better, faster, and more secure Python. As of October 4, 2021, the first official version is available.
Each new version of Python brings a host of changes. You can read about all of them in the documentation. Here, you’ll get to learn about the coolest new features.
In this tutorial, you’ll learn about:
- Debugging with more helpful and precise error messages
- Using structural pattern matching to work with data structures
- Adding more readable and more specific type hints
- Checking the length of sequences when using
zip() - Calculating multivariable statistics
To try out the new features yourself, you need to run Python 3.10. You can get it from the Python homepage. Alternatively, you can use Docker with the latest Python image.
Free Bonus: 5 Thoughts On Python Mastery, a free course for Python developers that shows you the roadmap and the mindset you’ll need to take your Python skills to the next level.
Bonus Learning Materials: Check out Real Python Podcast Episode #81 for Python 3.10 tips and a discussion with members of the Real Python team.
Better Error Messages
Python is often lauded for being a user-friendly programming language. While this is true, there are certain parts of Python that could be friendlier. Python 3.10 comes with a host of more precise and constructive error messages. In this section, you’ll see some of the newest improvements. The full list is available in the documentation.
Think back to writing your first Hello World program in Python:
# hello.py
print("Hello, World!)
Maybe you created a file, added the famous call to print(), and saved it as hello.py. You then ran the program, eager to call yourself a proper Pythonista. However, something went wrong:
$ python hello.py
File "/home/rp/hello.py", line 3
print("Hello, World!)
^
SyntaxError: EOL while scanning string literal
There was a SyntaxError in the code. EOL, what does that even mean? You went back to your code, and after a bit of staring and searching, you realized that there was a missing quotation mark at the end of your string.
One of the more impactful improvements in Python 3.10 is better and more precise error messages for many common issues. If you run your buggy Hello World in Python 3.10, you’ll get a bit more help than in earlier versions of Python:
$ python hello.py
File "/home/rp/hello.py", line 3
print("Hello, World!)
^
SyntaxError: unterminated string literal (detected at line 3)
The error message is still a bit technical, but gone is the mysterious EOL. Instead, the message tells you that you need to terminate your string! There are similar improvements to many different error messages, as you’ll see below.
A SyntaxError is an error raised when your code is parsed, before it even starts to execute. Syntax errors can be tricky to debug because the interpreter provides imprecise or sometimes even misleading error messages. The following code is missing a curly brace to terminate the dictionary:
1# unterminated_dict.py
2
3months = {
4 10: "October",
5 11: "November",
6 12: "December"
7
8print(f"{months[10]} is the tenth month")
The missing closing curly brace that should have been on line 7 is an error. If you run this code with Python 3.9 or earlier, you’ll see the following error message:
File "/home/rp/unterminated_dict.py", line 8
print(f"{months[10]} is the tenth month")
^
SyntaxError: invalid syntax
The error message highlights line 8, but there are no syntactical problems in line 8! If you’ve experienced your share of syntax errors in Python, you might already know that the trick is to look at the lines before the one Python complains about. In this case, you’re looking for the missing closing brace on line 7.
In Python 3.10, the same code shows a much more helpful and precise error message:
File "/home/rp/unterminated_dict.py", line 3
months = {
^
SyntaxError: '{' was never closed
This points you straight to the offending dictionary and allows you to fix the issue in no time.
There are a few other ways to mess up dictionary syntax. A typical one is forgetting a comma after one of the items:
1# missing_comma.py
2
3months = {
4 10: "October"
5 11: "November",
6 12: "December",
7}
In this code, a comma is missing at the end of line 4. Python 3.10 gives you a clear suggestion on how to fix your code:
File "/home/real_python/missing_comma.py", line 4
10: "October"
^^^^^^^^^
SyntaxError: invalid syntax. Perhaps you forgot a comma?
You can add the missing comma and have your code back up and running in no time.
Another common mistake is using the assignment operator (=) instead of the equality comparison operator (==) when you’re comparing values. Previously, this would just cause another invalid syntax message. In the newest version of Python, you get some more advice:
>>> if month = "October":
File "<stdin>", line 1
if month = "October":
^^^^^^^^^^^^^^^^^
SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?
The parser suggests that you maybe meant to use a comparison operator or an assignment expression operator instead.
Take note of another nifty improvement in Python 3.10 error messages. The last two examples show how carets (^^^) highlight the whole offending expression. Previously, a single caret symbol (^) indicated just an approximate location.
The final error message improvement that you’ll play with for now is that attribute and name errors can now offer suggestions if you misspell an attribute or a name:
>>> import math
>>> math.py
AttributeError: module 'math' has no attribute 'py'. Did you mean: 'pi'?
>>> pint
NameError: name 'pint' is not defined. Did you mean: 'print'?
>>> release = "3.10"
>>> relaese
NameError: name 'relaese' is not defined. Did you mean: 'release'?
Note that the suggestions work for both built-in names and names that you define yourself, although they may not be available in all environments. If you like these kinds of suggestions, check out BetterErrorMessages, which offers similar suggestions in even more contexts.
The improvements you’ve seen in this section are just some of the many error messages that have gotten a face-lift. The new Python will be even more user-friendly than before, and hopefully, the new error messages will save you both time and frustration going forward.
Structural Pattern Matching
The biggest new feature in Python 3.10, probably both in terms of controversy and potential impact, is structural pattern matching. Its introduction has sometimes been referred to as switch ... case coming to Python, but you’ll see that structural pattern matching is much more powerful than that.
You’ll see three different examples that together highlight why this feature is called structural pattern matching and show you how you can use this new feature:
- Detecting and deconstructing different structures in your data
- Using different kinds of patterns
- Matching literal patterns
Structural pattern matching is a comprehensive addition to the Python language. To give you a taste of how you can take advantage of it in your own projects, the next three subsections will dive into some of the details. You’ll also see some links that can help you explore in even more depth if you want.
Deconstructing Data Structures
At its core, structural pattern matching is about defining patterns to which your data structures can be matched. In this section, you’ll study a practical example where you’ll work with data that are structured differently, even though the meaning is the same. You’ll define several patterns, and depending on which pattern matches your data, you’ll process your data appropriately.
This section will be a bit light on explanations of the possible patterns. Instead, it will try to give you an impression of the possibilities. The next section will step back and explain the patterns in more detail.
Time to match your first pattern! The following example uses a match ... case block to find the first name of a user by extracting it from a user data structure:
>>> user = {
... "name": {"first": "Pablo", "last": "Galindo Salgado"},
... "title": "Python 3.10 release manager",
... }
>>> match user:
... case {"name": {"first": first_name}}:
... pass
...
>>> first_name
'Pablo'
You can see structural pattern matching at work in the highlighted lines. user is a small dictionary with user information. The case line specifies a pattern that user is matched against. In this case, you’re looking for a dictionary with a "name" key whose value is a new dictionary. This nested dictionary has a key called "first". The corresponding value is bound to the variable first_name.
For a practical example, say that you’re processing user data where the underlying data model changes over time. Therefore, you need to be able to process different versions of the same data.
In the next example, you’ll use data from randomuser.me. This is a great API for generating random user data that you can use during testing and development. The API is also an example of an API that has changed over time. You can still access the old versions of the API.
You may expand the collapsed section below to see how you can use requests to obtain different versions of the user data using the API:
You can get a random user from the API using requests as follows:
# random_user.py
import requests
def get_user(version="1.3"):
"""Get random users"""
url = f"https://randomuser.me/api/{version}/?results=1"
response = requests.get(url)
if response:
return response.json()["results"][0]
get_user() gets one random user in JSON format. Note the version parameter. The structure of the returned data has changed quite a bit between earlier versions like "1.1" and the current version "1.3", but in each case, the actual user data are contained in a list inside the "results" array. The function returns the first—and only—user in this list.
At the time of writing, the latest version of the API is 1.3 and the data has the following structure:
{
"gender": "female",
"name": {
"title": "Miss",
"first": "Ilona",
"last": "Jokela"
},
"location": {
"street": {
"number": 4473,
"name": "Mannerheimintie"
},
"city": "Harjavalta",
"state": "Ostrobothnia",
"country": "Finland",
"postcode": 44879,
"coordinates": {
"latitude": "-6.0321",
"longitude": "123.2213"
},
"timezone": {
"offset": "+5:30",
"description": "Bombay, Calcutta, Madras, New Delhi"
}
},
"email": "ilona.jokela@example.com",
"login": {
"uuid": "632b7617-6312-4edf-9c24-d6334a6af52d",
"username": "brownsnake482",
"password": "biatch",
"salt": "ofk518ZW",
"md5": "6d589615ca44f6e583c85d45bf431c54",
"sha1": "cd87c931d579bdff77af96c09e0eea82d1edfc19",
"sha256": "6038ede83d4ce74116faa67fb3b1b2e6f6898e5749b57b5a0312bd46a539214a"
},
"dob": {
"date": "1957-05-20T08:36:09.083Z",
"age": 64
},
"registered": {
"date": "2006-07-30T18:39:20.050Z",
"age": 15
},
"phone": "07-369-318",
"cell": "048-284-01-59",
"id": {
"name": "HETU",
"value": "NaNNA204undefined"
},
"picture": {
"large": "https://randomuser.me/api/portraits/women/28.jpg",
"medium": "https://randomuser.me/api/portraits/med/women/28.jpg",
"thumbnail": "https://randomuser.me/api/portraits/thumb/women/28.jpg"
},
"nat": "FI"
}
One of the members that changed between different versions is "dob", the date of birth. Note that in version 1.3, this is a JSON object with two members, "date" and "age".
Note: By default, randomuser.me returns a random user. You can get the exact same user as in this example by setting the seed to 310:
url = f"https://randomuser.me/api/{version}/?results=1&seed=310"
You set the seed by adding &seed=310 to the URL. The full object returned by the API also contains some metadata in a member named "info". These metadata will include the version of the data as well as the seed used to create the random user.
Compare the result above with a version 1.1 random user:
{
"gender": "female",
"name": {
"title": "miss",
"first": "ilona",
"last": "jokela"
},
"location": {
"street": "7336 myllypuronkatu",
"city": "kurikka",
"state": "central ostrobothnia",
"postcode": 53740
},
"email": "ilona.jokela@example.com",
"login": {
"username": "blackelephant837",
"password": "sand",
"salt": "yofk518Z",
"md5": "b26367ea967600d679ee3e0b9bda012f",
"sha1": "87d2910595acba5b8e8aa8b00a841bab08580e2f",
"sha256": "73bd0d205d0dc83ae184ae222ff2e9de5ea4039119a962c4f97fabd5bbfa7aca"
},
"dob": "1966-04-17 11:57:01",
"registered": "2005-08-10 10:15:01",
"phone": "04-636-931",
"cell": "048-828-40-15",
"id": {
"name": "HETU",
"value": "366-9204"
},
"picture": {
"large": "https://randomuser.me/api/portraits/women/24.jpg",
"medium": "https://randomuser.me/api/portraits/med/women/24.jpg",
"thumbnail": "https://randomuser.me/api/portraits/thumb/women/24.jpg"
},
"nat": "FI"
}
Observe that in this older format, the value of the "dob" member is a plain string.
In this example, you’ll work with the information about the date of birth (dob) for each user. The structure of these data has changed between different versions of the Random User API:
# Version 1.1
"dob": "1966-04-17 11:57:01"
# Version 1.3
"dob": {"date": "1957-05-20T08:36:09.083Z", "age": 64}
Note that in version 1.1, the date of birth is represented as a simple string, while in version 1.3, it’s a JSON object with two members: "date" and "age". Say that you want to find the age of a user. Depending on the structure of your data, you’d either need to calculate the age based on the date of birth or look up the age if it’s already available.
Note: The value of age is accurate when you download the data. If you store the data, this value will eventually become outdated. If this is a concern, you should calculate the current age based on date.
Traditionally, you would detect the structure of the data with an if test, maybe based on the type of the "dob" field. You can approach this differently in Python 3.10. Now, you can use structural pattern matching instead:
1# random_user.py (continued)
2
3from datetime import datetime
4
5def get_age(user):
6 """Get the age of a user"""
7 match user:
8 case {"dob": {"age": int(age)}}:
9 return age
10 case {"dob": dob}:
11 now = datetime.now()
12 dob_date = datetime.strptime(dob, "%Y-%m-%d %H:%M:%S")
13 return now.year - dob_date.year
The match ... case construct is new in Python 3.10 and is how you perform structural pattern matching. You start with a match statement that specifies what you want to match. In this example, that’s the user data structure.
One or several case statements follow match. Each case describes one pattern, and the indented block beneath it says what should happen if there’s a match. In this example:
-
Line 8 matches a dictionary with a
"dob"key whose value is another dictionary with an integer (int) item named"age". The nameagecaptures its value. -
Line 10 matches any dictionary with a
"dob"key. The namedobcaptures its value.
One important feature of pattern matching is that at most one pattern will be matched. Since the pattern on line 10 matches any dictionary with "dob", it’s important that the more specific pattern on line 8 comes first.
Note: The age calculation done on line 13 is not very precise since it ignores dates. You can improve on this by explicitly comparing months and days to check whether the user has already celebrated their birthday this year. However, a better solution would be to use relativedelta from the dateutil package. By using relativedelta, you can calculate years directly.
Before looking closer at the details of the patterns and how they work, try calling get_age() with different data structures to see the result:
>>> import random_user
>>> users11 = random_user.get_user(version="1.1")
>>> random_user.get_age(users11)
55
>>> users13 = random_user.get_user(version="1.3")
>>> random_user.get_age(users13)
64
Your code can calculate the age correctly for both versions of the user data, which have different dates of birth.
Look closer at those patterns. The first pattern, {"dob": {"age": int(age)}}, matches version 1.3 of the user data:
{
...
"dob": {"date": "1957-05-20T08:36:09.083Z", "age": 64},
...
}
The first pattern is a nested pattern. The outer curly braces say that a dictionary with the key "dob" is required. The corresponding value should be a dictionary. This nested dictionary must match the subpattern {"age": int(age)}. In other words, it needs to have an "age" key with an integer value. That value is bound to the name age.
The second pattern, {"dob": dob}, matches the older version 1.1 of the user data:
{
...
"dob": "1966-04-17 11:57:01",
...
}
This second pattern is a simpler pattern than the first one. Again, the curly braces indicate that it will match a dictionary. However, any dictionary with a "dob" key is matched because there are no other restrictions specified. The value of that key is bound to the name dob.
The main takeaway is that you can describe the structure of your data using mostly familiar notation. One striking change, though, is that you can use names like dob and age, which aren’t yet defined. Instead, values from your data are bound to these names when a pattern matches.
You’ve explored some of the power of structural pattern matching in this example. In the next section, you’ll dive a bit more into the details.
Using Different Kinds of Patterns
You’ve seen an example of how you can use patterns to effectively unravel complicated data structures. Now, you’ll take a step back and look at the building blocks that make up this new feature. Many things come together to make it work. In fact, there are three Python Enhancement Proposals (PEPs) that describe structural pattern matching:
- PEP 634: Specification
- PEP 635: Motivation and Rationale
- PEP 636: Tutorial
These documents give you a lot of background and detail if you’re interested in a deeper dive than what follows.
Patterns are at the center of structural pattern matching. In this section, you’ll learn about some of the different kinds of patterns that exist:
- Mapping patterns match mapping structures like dictionaries.
- Sequence patterns match sequence structures like tuples and lists.
- Capture patterns bind values to names.
- AS patterns bind the value of subpatterns to names.
- OR patterns match one of several different subpatterns.
- Wildcard patterns match anything.
- Class patterns match class structures.
- Value patterns match values stored in attributes.
- Literal patterns match literal values.
You already used several of them in the example in the previous section. In particular, you used mapping patterns to unravel data stored in dictionaries. In this section, you’ll learn more about how some of these work. All the details are available in the PEPs mentioned above.
A capture pattern is used to capture a match to a pattern and bind it to a name. Consider the following recursive function that sums a list of numbers:
1def sum_list(numbers):
2 match numbers:
3 case []:
4 return 0
5 case [first, *rest]:
6 return first + sum_list(rest)
The first case on line 3 matches the empty list and returns 0 as its sum. The second case on line 5 uses a sequence pattern with two capture patterns to match lists with one or more elements. The first element in the list is captured and bound to the name first. The second capture pattern, *rest, uses unpacking syntax to match any number of elements. rest will bind to a list containing all elements of numbers except the first one.
sum_list() calculates the sum of a list of numbers by recursively adding the first number in the list and the sum of the rest of the numbers. You can use it as follows:
>>> sum_list([4, 5, 9, 4])
22
The sum of 4 + 5 + 9 + 4 is correctly calculated to be 22. As an exercise for yourself, you can try to trace the recursive calls to sum_list() to make sure you understand how the code sums the whole list.
Note: Capture patterns essentially assign values to variables. However, one limitation is that only undotted names are allowed. In other words, you can’t use a capture pattern to assign to a class or instance attribute directly.
sum_list() handles summing up a list of numbers. Observe what happens if you try to sum anything that isn’t a list:
>>> print(sum_list("4594"))
None
>>> print(sum_list(4594))
None
Passing a string or a number to sum_list() returns None. This occurs because none of the patterns match, and the execution continues after the match block. That happens to be the end of the function, so sum_list() implicitly returns None.
Often, though, you want to be alerted about failed matches. You can add a catchall pattern as the final case that handles this by raising an error, for example. You can use the underscore (_) as a wildcard pattern that matches anything without binding it to a name. You can add some error handling to sum_list() as follows:
def sum_list(numbers):
match numbers:
case []:
return 0
case [first, *rest]:
return first + sum_list(rest)
case _:
wrong_type = numbers.__class__.__name__
raise ValueError(f"Can only sum lists, not {wrong_type!r}")
The final case will match anything that doesn’t match the first two patterns. This will raise a descriptive error, for instance, if you try to calculate sum_list(4594). This is useful when you need to alert your users that some input was not matched as expected.
Your patterns are still not foolproof, though. Consider what happens if you try to sum a list of strings:
>>> sum_list(["45", "94"])
TypeError: can only concatenate str (not "int") to str
The base case returns 0, so therefore the summing only works for types that you can add with numbers. Python doesn’t know how to add numbers and text strings together. You can restrict your pattern to only match integers using a class pattern:
def sum_list(numbers):
match numbers:
case []:
return 0
case [int(first), *rest]:
return first + sum_list(rest)
case _:
raise ValueError(f"Can only sum lists of numbers")
Adding int() around first makes sure that the pattern only matches if the value is an integer. This might be too restrictive, though. Your function should be able to sum both integers and floating-point numbers, so how can you allow this in your pattern?
To check whether at least one out of several subpatterns match, you can use an OR pattern. OR patterns consist of two or more subpatterns, and the pattern matches if at least one of the subpatterns does. You can use this to match when the first element is either of type int or type float:
def sum_list(numbers):
match numbers:
case []:
return 0
case [int(first) | float(first), *rest]:
return first + sum_list(rest)
case _:
raise ValueError(f"Can only sum lists of numbers")
You use the pipe symbol (|) to separate the subpatterns in an OR pattern. Your function now allows summing a list of floating-point numbers:
>>> sum_list([45.94, 46.17, 46.72])
138.82999999999998
There’s a lot of power and flexibility within structural pattern matching, even more than what you’ve seen so far. Some things that aren’t covered in this overview are:
- Using guards to restrict patterns
- Using AS patterns to capture the value of subpatterns
- Using class patterns to match custom enums and data classes
If you’re interested, have a look in the documentation to learn more about these features as well. In the next section, you’ll learn about literal patterns and value patterns.
Matching Literal Patterns
A literal pattern is a pattern that matches a literal object like an explicit string or number. In a sense, this is the most basic kind of pattern and allows you to emulate switch ... case statements seen in other languages. The following example matches a specific name:
def greet(name):
match name:
case "Guido":
print("Hi, Guido!")
case _:
print("Howdy, stranger!")
The first case matches the literal string "Guido". In this case, you use _ as a wildcard to print a generic greeting whenever name is not "Guido". Such literal patterns can sometimes take the place of if ... elif ... else constructs and can play the same role that switch ... case does in some other languages.
One limitation with structural pattern matching is that you can’t directly match values stored in variables. Say that you’ve defined bdfl = "Guido". A pattern like case bdfl: will not match "Guido". Instead, this will be interpreted as a capture pattern that matches anything and binds that value to bdfl, effectively overwriting the old value.
You can, however, use a value pattern to match stored values. A value pattern looks a bit like a capture pattern but uses a previously defined dotted name that holds the value that will be matched against.
Note: A dotted name is a name with a dot (.) in it. In practice, this will reference an attribute of either a class, an instance of a class, an enumeration, or a module.
You can, for example, use an enumeration to create such dotted names:
import enum
class Pythonista(str, enum.Enum):
BDFL = "Guido"
FLUFL = "Barry"
def greet(name):
match name:
case Pythonista.BDFL:
print("Hi, Guido!")
case _:
print("Howdy, stranger!")
The first case now uses a value pattern to match Pythonista.BDFL, which is "Guido". Note that you can use any dotted name in a value pattern. You could, for example, have used a regular class or a module instead of the enumeration.
To see a bigger example of how to use literal patterns, consider the game of FizzBuzz. This is a counting game where you should replace some numbers with words according to the following rules:
- You replace numbers divisible by 3 with fizz.
- You replace numbers divisible by 5 with buzz.
- You replace numbers divisible by both 3 and 5 with fizzbuzz.
FizzBuzz is sometimes used to introduce conditionals in programming education and as a screening problem in interviews. Even though a solution is quite straightforward, Joel Grus has written a full book about different ways to program the game.
A typical solution in Python will use if ... elif ... else as follows:
def fizzbuzz(number):
mod_3 = number % 3
mod_5 = number % 5
if mod_3 == 0 and mod_5 == 0:
return "fizzbuzz"
elif mod_3 == 0:
return "fizz"
elif mod_5 == 0:
return "buzz"
else:
return str(number)
The % operator calculates the modulus, which you can use to test divisibility. Namely, if a modulus b is 0 for two numbers a and b, then a is divisible by b.
In fizzbuzz(), you calculate number % 3 and number % 5, which you then use to test for divisibility with 3 and 5. Note that you must do the test for divisibility with both 3 and 5 first. If not, numbers that are divisible by both 3 and 5 will be covered by either the "fizz" or the "buzz" cases instead.
You can check that your implementation gives the expected result:
>>> fizzbuzz(3)
fizz
>>> fizzbuzz(14)
14
>>> fizzbuzz(15)
fizzbuzz
>>> fizzbuzz(92)
92
>>> fizzbuzz(65)
buzz
You can confirm for yourself that 3 is divisible by 3, 65 is divisible by 5, and 15 is divisible by both 3 and 5, while 14 and 92 aren’t divisible by either 3 or 5.
An if ... elif ... else structure where you’re comparing one or a few variables several times over is quite straightforward to rewrite using pattern matching instead. For example, you can do the following:
def fizzbuzz(number):
mod_3 = number % 3
mod_5 = number % 5
match (mod_3, mod_5):
case (0, 0):
return "fizzbuzz"
case (0, _):
return "fizz"
case (_, 0):
return "buzz"
case _:
return str(number)
You match on both mod_3 and mod_5. Each case pattern then matches either the literal number 0 or the wildcard _ on the corresponding values.
Compare and contrast this version with the previous one. Note how the pattern (0, 0) corresponds to the test mod_3 == 0 and mod_5 == 0, while (0, _) corresponds to mod_3 == 0.
As you saw earlier, you can use an OR pattern to match on several different patterns. For example, since mod_3 can only take the values 0, 1, and 2, you can replace case (_, 0) with case (1, 0) | (2, 0). Remember that (0, 0) has already been covered.
Note: If you’ve been using switch ... case in other languages, you should remember that there’s no fallthrough in Python’s pattern matching. That means that at most one case will ever be executed, namely the first case that matches. This is different from languages like C and Java. You can handle most of the effects of fallthroughs with OR patterns.
The Python core developers have consciously chosen not to include switch ... case statements in the language earlier. However, there are some third-party packages that do, like switchlang, which