Data and control structures

Python Developer 1 in a nutshell

Author

Karsten Naert

Published

October 20, 2026

Data Structures

Important methods

There are two important data structures you should remember:

  • lists
  • dicts

Two more data structures are a little less important:

  • tuples
  • sets

These structures are often called containers because they contain themselves other data. (Not to be confused with Docker containers!) We will assume you are familiar with the basics of how these data structures work. Here are a few commands that may be new:

You may know .append for a list, but there is also .insert to insert a value at a given position.

my_list = [1, 2, 3, 4, 5]
my_list.insert(2, "new!")

my_list
[1, 2, 'new!', 3, 4, 5]
  • You may also know my_dict["a"], but did you know .get?
my_dict = {"a": 100, "b": 200}
my_dict.get("a")
100

There is an important difference though! my_dict["a"] will raise an exception when "a" is not a key in the dict. What will .get do?

d = {"a": 10}
d.get("b")

If you see nothing, it usually means the last value was None. We can make it more explicit by forcing Python to print it.

d = {"a": 10}
print(d.get("b"))
None

The default value can be provided as an extra argument to the .get method:

d.get("b", "default_value")
'default_value'

Tuples are also quite interesting. It is in fact the comma that makes the tuple, not the parentheses:

x = (5, 6)  # ok, recommended
x = 5, 6  # also ok, not recommended

Nevertheless it is recommended to use parentheses anyway. A tuple with one element can be made like this:

x = (3,)  # tuple with one element

The following also creates a tuple with one element, but it is confusing and suggests a typo was made, so don’t use it:

x = 3,

You can do some interesting things with lists for instance: add them to themselves as a member:

my_list = []
my_list.append(my_list)
my_list
[[...]]

Construct two lists that reference each other: list1 = [1, list2] and list2 = [2, list1]. Investigate how Python prints these.

Comprehensions

A comprehension is a piece of Python syntax that allows us to very easily create a new container out of another one1. Usually it is used for lists and dicts, but it can also work for tuples or sets2. Some examples:

[x**2 for x in range(5)]  # list comprehension
{x**2: x for x in range(5)}  # dict comprehension
{x for x in range(5)}  # set comprehension
tuple(x for x in  range(5))  # "tuple comprehension"

Control Structures

for and if

Control structures direct the flow of code. They can make us repeat a line several times, skip a line, or back to an earlier line. The two most important control structures are for and if.

x = 10
if x < 5:
    print("x is small")
else:
    print("x is large")
x is large
for i in range(1, 11):
    print(f"{i} x 7 = {7 * i}")
1 x 7 = 7
2 x 7 = 14
3 x 7 = 21
4 x 7 = 28
5 x 7 = 35
6 x 7 = 42
7 x 7 = 49
8 x 7 = 56
9 x 7 = 63
10 x 7 = 70

Looping with enumerate and zip

Two builtins turn up constantly in for loops. enumerate gives you the index alongside the value, so you never have to write for i in range(len(names)):

names = ["jan", "piet", "joris"]
for i, name in enumerate(names):
    print(i, name)
0 jan
1 piet
2 joris

It takes a start argument if you want to count from 1:

for i, name in enumerate(names, start=1):
    print(f"{i}. {name}")
1. jan
2. piet
3. joris

zip walks over several containers at the same time:

ages = [30, 40, 50]
for name, age in zip(names, ages):
    print(f"{name} is {age}")
jan is 30
piet is 40
joris is 50

There is a trap here. By default zip stops as soon as the shortest container runs out, silently ignoring the rest:

ages = [30, 40]
for name, age in zip(names, ages):
    print(f"{name} is {age}")
jan is 30
piet is 40

Poor joris. Almost always this means you have a bug somewhere else in your program. Since Python 3.10 you can ask zip to complain instead, and I recommend you do so whenever the containers are supposed to have the same length:

for name, age in zip(names, ages, strict=True):
    print(f"{name} is {age}")
jan is 30
piet is 40
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[17], line 1
----> 1 for name, age in zip(names, ages, strict=True):
      2     print(f"{name} is {age}")

ValueError: zip() argument 2 is shorter than argument 1

Testing whether a container is empty

To test if a container is empty, the officially recommended way is with if my_container:, as follows:

first_list = []
second_list = [1]

if first_list:
    print("First list is not empty!")
if second_list:
    print("Second list is not empty!")
Second list is not empty!

In practice however, it depends. If first_list were accidentally None or 0, the code above would still quietly take the “empty” branch, even though we no longer have a list at all. Writing len(...) instead does not hide that mistake, it raises a TypeError and tells you something is wrong:

if len(first_list):
    print("First list is not empty!")
if len(second_list):
    print("Second list is not empty!")
Second list is not empty!
not_a_list = None
if len(not_a_list):
    print("This line is never reached")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[20], line 2
      1 not_a_list = None
----> 2 if len(not_a_list):
      3     print("This line is never reached")

TypeError: object of type 'NoneType' has no len()

So if my_container: is shorter and more Pythonic, while if len(my_container): is stricter about what it accepts. Use the first one by default; reach for the second when you would rather find out loudly that you were handed the wrong type.

break and continue

These two important keywords can help you write your code a bit more efficiently.

  • break stops the current for loop and breaks out of it to the next block of code
  • continue stops the current iteration of the loop and immediately goes to the next

Here are some typical use cases for each:

  • break: can help you find an object as we are iterating through a container. For instance, this lets us find the first name with an i in it 3
names = ["jan", "piet", "joris", "korneel"]

for name in names:
    if 'i' in name:
        break
print(f"First name containing an i: {name}")
First name containing an i: piet

Be careful with this pattern: if no name matches, we never break and name is simply left holding the last item of the list. Guarding against that is exactly what the for ... else construct below is for.

  • continue: can act as a filter as we are iterating. For instance, say we want to print only the names that do not have a letter i in them:
for name in names:
    if 'i' in name:
        continue
    print(name)
jan
korneel

An alternative to this last example would be to use if 'i' not in name: ... but this has the downside that line print(name), which can in practice be a larger block will be indented one more level.

for name in names:
    if "i" in name:
        raise ValueError
    function1()
    function2()
    ...
    function100()
for name in names:
    if "i" not in name:
        function1()
        function2()
        ...
        function100()
    else:
        raise ValueError

One should think of this as processing the special cases, exceptions, … in advance to have the general case more prominently featured. Doing it like this has the benefit of communicating to other programmers what is the general case and what is the exception.

for … else

Python has a special construct where a for-loop can have an else-clause.

Its intended use is to trigger when the loop finishes normally, i.e. when the iterator is exhausted. It is skipped if we leave the loop through a break. For instance:

for x in range(5):
    print(f"We are testing whether {x} = 3")
    if x == 3:
        print("Got it!")
        break
else:
    print("Didn't find it :-/ ")
We are testing whether 0 = 3
We are testing whether 1 = 3
We are testing whether 2 = 3
We are testing whether 3 = 3
Got it!
for x in range(3):
    print(f"We are testing whether {x} = 3")
    if x == 3:
        print("Got it!")
        break
else:
    print("Didn't find it :-/ ")
We are testing whether 0 = 3
We are testing whether 1 = 3
We are testing whether 2 = 3
Didn't find it :-/ 

It is rarely used, and a lot of programmers don’t even know it exists. For that reason I recommend trying to avoid it when collaborating with other people. There are often other ways to achieve the same thing, for instance by using a function or initializing a variable beforehand:

result = "Didn't find it :-/ "
for x in range(3):
    if x == 3:
        result = "Got it!"
        break
print(result)
Didn't find it :-/ 

while

A while loop continues to iterate for as long as a condition remains satisfied.

total = 0
i = 0
while total < 10_000:
    i += 1
    total += i
print(i)
141

In general if you know an upper bound to the total number of iterations, it is more recommended to use a for loop: this should make the code easier to read and understand.

For instance, by rewriting as follows we are immediately making clear that this code will run for at most 10000 iterations.

total = 0
for i in range(10_000):
    total += i
    if total > 10_000:
        break
print(i)
141

functions

An option to simplify complex bits of control structure is to move a piece to a separate function. An early return can then play a role comparable to a continue or break:

def find_in_list(my_list, x):
    for item in my_list:
        if item == x:
            # Don't need a break since we can return
            return "Got it!"
    return "Didn't find it :-/ "

find_in_list(range(5), 7)
"Didn't find it :-/ "

The following Python code was written by Claude 4 Sonnet.

Rewrite the code to make it easier to understand and maintain.

#!/usr/bin/env python3
import subprocess
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
import os

class WebhookHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == '/webhook':
            # Read the payload
            content_length = int(self.headers['Content-Length'])
            payload = self.rfile.read(content_length)
            
            # Basic validation (optional - add GitHub secret validation for security)
            try:
                data = json.loads(payload)
                if data.get('ref') == 'refs/heads/main':  # or your default branch
                    self.rebuild_docs()
                    self.send_response(200)
                    self.end_headers()
                    self.wfile.write(b'OK')
                else:
                    self.send_response(200)
                    self.end_headers()
            except:
                self.send_response(400)
                self.end_headers()
        else:
            self.send_response(404)
            self.end_headers()
    
    def rebuild_docs(self):
        # Change to your project directory
        os.chdir('/path/to/your/project')
        subprocess.run(['git', 'pull'])
        subprocess.run(['quarto', 'render'])

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 8080), WebhookHandler)
    print("Webhook server running on port 8080")
    server.serve_forever()

(Note that this code has other problems too: the quarto render may take so long that the web request could time out, and it does os.chdir without going back to the folder.)

Write some code that will make the names in a list of names unique by appending numbers.

names1 = ['jan', 'piet', 'joris', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2']
names2 = ['jan', 'piet', 'joris', 'jan', 'piet']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2']
namen3 = ['jan', 'piet', 'joris', 'jan', 'piet', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2', 'jan 3']

On Github you can find a list with data about all Pokémon

Which Pokémon has the longest name? Remove quotes if necessary.

Which Pokémon has the longest name if you remove special characters?

Write a piece of code that will nicely print a table of Pokémon information to the terminal:

┌─────┬────────────┬───────┬────────┬───────┬────┬────────┬─────────┬─────────┬─────────┬───────┬─────────────┐
│  ID │       Name │ Type1 │  Type2 │ Total │ HP │ Attack │ Defense │ Sp. Atk │ Sp. Def │ Speed │ Generation  │
├─────┼────────────┼───────┼────────┼───────┼────┼────────┼─────────┼─────────┼─────────┼───────┼─────────────┤
│   1 │  Bulbasaur │ Grass │ Poison │   318 │ 45 │     49 │      49 │      65 │      65 │    45 │          1  │
│   2 │    Ivysaur │ Grass │ Poison │   405 │ 60 │     62 │      63 │      80 │      80 │    60 │          1  │
│   3 │   Venusaur │ Grass │ Poison │   525 │ 80 │     82 │      83 │     100 │     100 │    80 │          1  │
│   4 │ Charmander │  Fire │        │   309 │ 39 │     52 │      43 │      60 │      50 │    65 │          1  │
│   5 │ Charmeleon │  Fire │        │   405 │ 58 │     64 │      58 │      80 │      65 │    80 │          1  │
│   6 │  Charizard │  Fire │ Flying │   534 │ 78 │     84 │      78 │     109 │      85 │   100 │          1  │
│   7 │   Squirtle │ Water │        │   314 │ 44 │     48 │      65 │      50 │      64 │    43 │          1  │
│   8 │  Wartortle │ Water │        │   405 │ 59 │     63 │      80 │      65 │      80 │    58 │          1  │
│   9 │  Blastoise │ Water │        │   530 │ 79 │     83 │     100 │      85 │     105 │    78 │          1  │
└─────┴────────────┴───────┴────────┴───────┴────┴────────┴─────────┴─────────┴─────────┴───────┴─────────────┘

Note that column 2 has disappeared. See wikipedia for information about box-drawing characters.

Exceptions

try … except

You have probably encountered exceptions before, since in Python they are usually what gives you headaches.

Often you start with an ordinary function:

def starts_with_b(word: str) -> bool:
    return word[0].lower() == 'b'

starts_with_b("Belgium"), starts_with_b("France")
(True, False)

And then you call it with an unexpected input, and an exception occurs, Python is basically telling you it could not proceed with the execution.

starts_with_b("")
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[30], line 1
----> 1 starts_with_b("")

Cell In[29], line 2, in starts_with_b(word)
      1 def starts_with_b(word: str) -> bool:
----> 2     return word[0].lower() == 'b'

IndexError: string index out of range

The exception in this case is an IndexError. A reasonable thing to do would be to clean up the code and guarantee that our function starts_with_b would not be triggered on an incorrect input4. But there is another way: which is to just to try to let the function do its thing, and if the IndexError exception occurs, deal with it5.

The syntax is as follows:

try:
    result = starts_with_b("")
    print(result)
except IndexError:
    print("Could not process the input")
Could not process the input

The exception has still occurred but it is handled. This means Python no longer suddenly interrupts and can continue to work after handling the exception. Of course, we need to be careful with what we do, for instance the value of result would not be accessible after the try ... except block if an IndexError was handled.

Other errors could still occur though! For instance, the following code generates a TypeError:

try:
    result = starts_with_b(0)
    print(result)
except IndexError:
    print("Could not process the input")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[32], line 2
      1 try:
----> 2     result = starts_with_b(0)
      3     print(result)
      4 except IndexError:

Cell In[29], line 2, in starts_with_b(word)
      1 def starts_with_b(word: str) -> bool:
----> 2     return word[0].lower() == 'b'

TypeError: 'int' object is not subscriptable

There are many types of exception that can occur. To handle them all use except Exception:

try:
    result = starts_with_b(0)
    print(result)
except Exception:
    print("Could not process the input")
Could not process the input

There also exists a so called bare except except:. This is considered poor practice, since it will also handle certain exceptions it really shouldn’t, such as KeyboardInterrupt.

try:
    result = starts_with_b(0)
    print(result)
except:  # DONT do this!
    print("Could not process the input")
Could not process the input

Sometimes people will say they catch an exception. This terminology comes from other languages, such as C++, where the terminology is not that you raise an exception and then handle it, but you throw an exception and catch it. In practice people will often use both pieces of terminology interchangeably.

raise

The exceptions we have seens so far were all triggered by Python itself, or libraries that we have used. With the raise statement we can also manually trigger an exception. This is useful when you want to signal that something has gone wrong in your code. It’s best to think of it as just another form of control flow.

Here’s the basic syntax:

def check_positive(number):
    if number <= 0:
        raise ValueError
    return number

check_positive(-5)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[35], line 6
      3         raise ValueError
      4     return number
----> 6 check_positive(-5)

Cell In[35], line 3, in check_positive(number)
      1 def check_positive(number):
      2     if number <= 0:
----> 3         raise ValueError
      4     return number

ValueError: 

You can also raise exceptions with a custom message:

def divide_numbers(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero!")
    return a / b

divide_numbers(10, 0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[36], line 6
      3         raise ZeroDivisionError("Cannot divide by zero!")
      4     return a / b
----> 6 divide_numbers(10, 0)

Cell In[36], line 3, in divide_numbers(a, b)
      1 def divide_numbers(a, b):
      2     if b == 0:
----> 3         raise ZeroDivisionError("Cannot divide by zero!")
      4     return a / b

ZeroDivisionError: Cannot divide by zero!

try … except … except

You can handle multiple different types of exceptions by using multiple except clauses. This is useful when different exceptions require different handling:

def safe_divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
        return None
    except TypeError:
        print("Error: Both arguments must be numbers!")
        return None

safe_divide(10, 2)
5.0
safe_divide(10, 0)
Error: Cannot divide by zero!
safe_divide(10, "hello")
Error: Both arguments must be numbers!

You can also handle multiple exceptions in a single except clause by using a tuple:

def process_number(value):
    try:
        number = int(value)
        result = 100 / number
        return result
    except (ValueError, TypeError):
        print("Error: Invalid input - must be a valid number!")
        return None
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
        return None

process_number("5")
20.0
process_number("hello")
Error: Invalid input - must be a valid number!
process_number("0")
Error: Cannot divide by zero!

The order of except clauses matters! Python will check them from top to bottom and execute the first one that matches. Always put more specific exceptions before more general ones:

def demonstrate_exception_order(value):
    try:
        number = int(value)
        result = 100 / number
        return result
    except ZeroDivisionError:  # Specific exception first
        print("Cannot divide by zero!")
        return 0
    except ValueError:  # More specific than Exception
        print("Invalid number format!")
        return None
    except Exception:  # Most general exception last
        print("Something unexpected happened!")
        return None

demonstrate_exception_order("0")
Cannot divide by zero!
0

Best practices:

  • Use specific exceptions whenever you can
  • You can use except Exception as a last resort
  • Avoid bare except unless you have a very good reason

try … except as

The as keyword allows you to capture the exception object itself, which can be useful for accessing error details or logging:

def divide_with_details(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError as e:
        print(f"Error occurred: {e}")
        print(f"Error type: {type(e).__name__}")
        return None

divide_with_details(10, 0)
Error occurred: division by zero
Error type: ZeroDivisionError

This is especially helpful when you need to log the specific error message or when you want to include the original error details in your own error handling:

def process_file_content(filename):
    try:
        with open(filename, 'r') as file:
            content = file.read()
            return len(content)
    except FileNotFoundError as e:
        print(f"Could not find file: {e}")
        return 0
    except PermissionError as e:
        print(f"Permission denied: {e}")
        return 0
    except Exception as e:
        print(f"Unexpected error: {e}")
        return 0

process_file_content("nonexistent.txt")
Could not find file: [Errno 2] No such file or directory: 'nonexistent.txt'
0

You can also use the exception object to make decisions about how to handle the error:

def safe_convert_to_int(value):
    try:
        return int(value)
    except ValueError as e:
        if "invalid literal" in str(e):
            print(f"'{value}' is not a valid number format")
        else:
            print(f"Value error: {e}")
        return None

safe_convert_to_int("hello")
'hello' is not a valid number format
safe_convert_to_int("123")
123

try … except … else … finally

Python’s try statement can have additional clauses beyond just except: else and finally. Each serves a specific purpose:

else clause

The else clause runs only if no exception occurred in the try block:

def divide_with_else(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Cannot divide by zero!")
        return None
    else:
        print("Division successful!")
        return result

divide_with_else(10, 2)
Division successful!
5.0
divide_with_else(10, 0)
Cannot divide by zero!

finally clause

The finally clause always runs, regardless of whether an exception occurred or not. Even if there is a return during the exception handling, the block will still be executed. This is useful for cleanup operations:

def process_data_with_cleanup(data):
    try:
        print("Starting processing...")
        result = int(data) * 2
        print(f"Result: {result}")
        return result
    except ValueError:
        print("Invalid input!")
        return None
    finally:
        print("Cleanup: Processing finished")

process_data_with_cleanup("5")
Starting processing...
Result: 10
Cleanup: Processing finished
10
process_data_with_cleanup("hello")
Starting processing...
Invalid input!
Cleanup: Processing finished

Combining all clauses

You can combine try, except, else, and finally together:

def complete_example(filename):
    file = None
    try:
        print(f"Trying to open {filename}")
        file = open(filename, 'r')
        content = file.read()
        print("File opened successfully")
    except FileNotFoundError:
        print("File not found!")
        return None
    except PermissionError:
        print("Permission denied!")
        return None
    else:
        print("File processing completed successfully")
        return len(content)
    finally:
        if file:
            file.close()
            print("File closed")
        else:
            print("No file to close")

complete_example("nonexistent.txt")
Trying to open nonexistent.txt
File not found!
No file to close

The execution order is:

  1. try block runs first
  2. If an exception occurs, the appropriate except block runs
  3. If no exception occurs, the else block runs (if present)
  4. The finally block always runs at the end

This pattern is particularly useful for resource management where you need to ensure cleanup happens regardless of success or failure.

Note

The example above is written the long way round on purpose, to show what finally is for. In real code you would never open a file like this: Python has dedicated syntax, with open(...) as f:, which does the closing for you. We pick that up in the next lecture.

raise from

There exists other, more specialized ways in which raise can occur. You will not see these too often but it’s good to be aware they exist:

  • raise ... from ... is used to raise an exception from a specific context
  • raise ... from None can be used to erase the context in which an exception is raised
  • raise without exception can be used during exception handling to reraise the last exception
def func1(a, b):
    return a / b

def func2(a, b, c):
    try:
        return func1(a, b) + c
    except ZeroDivisionError as e:
        # We handle the exception
        # But first we do something, perhaps we write some logging
        print("No idea what happened but it's a big fat problem!")
        # We can now re-raise the same exception
        raise
        
func2(3, 0, 10)
No idea what happened but it's a big fat problem!
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[53], line 14
     11         # We can now re-raise the same exception
     12         raise
---> 14 func2(3, 0, 10)

Cell In[53], line 6, in func2(a, b, c)
      4 def func2(a, b, c):
      5     try:
----> 6         return func1(a, b) + c
      7     except ZeroDivisionError as e:
      8         # We handle the exception
      9         # But first we do something, perhaps we write some logging
     10         print("No idea what happened but it's a big fat problem!")

Cell In[53], line 2, in func1(a, b)
      1 def func1(a, b):
----> 2     return a / b

ZeroDivisionError: division by zero

Here we handle the error and raise a different error of our own. Python will show this by saying “During the handling of this exception, a different exception occurred”:

def func1(a, b):
    return a / b

def func2(a, b, c):
    try:
        return func1(a, b) + c
    except ZeroDivisionError:
        raise ValueError("Some problem occurred during the calculation.")
        
func2(3, 0, 10)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[54], line 6, in func2(a, b, c)
      5 try:
----> 6     return func1(a, b) + c
      7 except ZeroDivisionError:

Cell In[54], line 2, in func1(a, b)
      1 def func1(a, b):
----> 2     return a / b

ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
Cell In[54], line 10
      7     except ZeroDivisionError:
      8         raise ValueError("Some problem occurred during the calculation.")
---> 10 func2(3, 0, 10)

Cell In[54], line 8, in func2(a, b, c)
      6     return func1(a, b) + c
      7 except ZeroDivisionError:
----> 8     raise ValueError("Some problem occurred during the calculation.")

ValueError: Some problem occurred during the calculation.

Python noticed the new exception was raised while handling the old one and chained them together for us. If you want to make that link explicit, use raise ... from .... Note that we now need the as e to have a name for the original exception. The traceback then reads “The above exception was the direct cause of the following exception”, which is a stronger statement: we are saying the ZeroDivisionError is the reason for our ValueError, not something that merely happened at the same time.

def func1(a, b):
    return a / b

def func2(a, b, c):
    try:
        return func1(a, b) + c
    except ZeroDivisionError as e:
        raise ValueError("Some problem occurred during the calculation.") from e
        
func2(3, 0, 10)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[55], line 6, in func2(a, b, c)
      5 try:
----> 6     return func1(a, b) + c
      7 except ZeroDivisionError as e:

Cell In[55], line 2, in func1(a, b)
      1 def func1(a, b):
----> 2     return a / b

ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

ValueError                                Traceback (most recent call last)
Cell In[55], line 10
      7     except ZeroDivisionError as e:
      8         raise ValueError("Some problem occurred during the calculation.") from e
---> 10 func2(3, 0, 10)

Cell In[55], line 8, in func2(a, b, c)
      6     return func1(a, b) + c
      7 except ZeroDivisionError as e:
----> 8     raise ValueError("Some problem occurred during the calculation.") from e

ValueError: Some problem occurred during the calculation.

If we raise from None there is no mention of the original ZeroDivisionError anymore:

def func1(a, b):
    return a / b

def func2(a, b, c):
    try:
        return func1(a, b) + c
    except ZeroDivisionError as e:
        # Here we handle the error and raise a new exception of our own
        # But we remove the context, i.e. the original error is no longer
        # in the traceback
        raise ValueError("Some problem occurred during the calculation.") from None
        
func2(3, 0, 10)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[56], line 13
      7     except ZeroDivisionError as e:
      8         # Here we handle the error and raise a new exception of our own
      9         # But we remove the context, i.e. the original error is no longer
     10         # in the traceback
     11         raise ValueError("Some problem occurred during the calculation.") from None
---> 13 func2(3, 0, 10)

Cell In[56], line 11, in func2(a, b, c)
      6     return func1(a, b) + c
      7 except ZeroDivisionError as e:
      8     # Here we handle the error and raise a new exception of our own
      9     # But we remove the context, i.e. the original error is no longer
     10     # in the traceback
---> 11     raise ValueError("Some problem occurred during the calculation.") from None

ValueError: Some problem occurred during the calculation.

Adding notes to an exception

Since Python 3.11 you can attach extra information to an exception without replacing it, using .add_note. This is handy when the code that catches the exception knows something useful that the code which raised it could not have known:

def load_config(filename):
    try:
        with open(filename) as f:
            return f.read()
    except FileNotFoundError as e:
        e.add_note("Looked for the config while starting up the application.")
        e.add_note("Hint: run `myapp --init` to create a default config file.")
        raise

load_config("settings.ini")
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[57], line 10
      7         e.add_note("Hint: run `myapp --init` to create a default config file.")
      8         raise
---> 10 load_config("settings.ini")

Cell In[57], line 3, in load_config(filename)
      1 def load_config(filename):
      2     try:
----> 3         with open(filename) as f:
      4             return f.read()
      5     except FileNotFoundError as e:

File /var/www/pydev2/.venv/lib/python3.13/site-packages/IPython/core/interactiveshell.py:343, in _modified_open(file, *args, **kwargs)
    336 if file in {0, 1, 2}:
    337     raise ValueError(
    338         f"IPython won't let you open fd={file} by default "
    339         "as it is likely to crash IPython. If you know what you are doing, "
    340         "you can use builtins' open."
    341     )
--> 343 return io_open(file, *args, **kwargs)

FileNotFoundError: [Errno 2] No such file or directory: 'settings.ini'
Looked for the config while starting up the application.
Hint: run `myapp --init` to create a default config file.

The notes are printed at the bottom of the traceback, after the error message.

Tip

Tracebacks have quietly become much more helpful in recent Python versions. Since 3.11 they underline the exact sub-expression that failed with ~~~^^^~~~ markers, which is a big help in a line like a["x"] / b["y"] where several things could have gone wrong. Since 3.13 they are also coloured by default in the terminal. If you are still reading tracebacks from an older Python, this alone is a good reason to upgrade.

Creating your own exceptions

Finally, it is possible to define your own exceptions apart from the existing IndexError, KeyError, … We will learn more about this in the lectures on Object Oriented Programming.


Next: Lesson 2: Bytes and files

Footnotes

  1. Technically out of an iterator.↩︎

  2. Strictly speaking there is no such thing as a “tuple comprehension”: the last line below is a generator expression that we then hand to tuple(...). For now you can safely read it as a comprehension; we come back to generators in the lectures on Architecture.↩︎

  3. There is another way using next.↩︎

  4. This is sometimes called asking permission↩︎

  5. Sometimes called asking forgiveness↩︎