Data and control structures

Python Developer 1 in a nutshell

Author

Karsten Naert

Published

November 15, 2025

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 sets. 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

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. For instance, if first_list were None or 0 the code will still behave identically despite it not even being a list. To guard against that it occasionally make sense to do it as follows:

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!

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 2
names = ["jan", "piet", "joris", "korneel"]

name = ""
for name in names:
    if 'i' in name:
        break
  • 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 if we break out of the loop in the normal way, but it is skipped by 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 :-/ "
Exercise

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[24], line 1
----> 1 starts_with_b("")

Cell In[23], 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 input3. But there is another way: which is to just to try to let the function do its thing, and if the NameError exception occurs, deal with it4.

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[26], line 2
      1 try:
----> 2     result = starts_with_b(0)
      3     print(result)
      4 except IndexError:

Cell In[23], 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[29], line 6
      3         raise ValueError
      4     return number
----> 6 check_positive(-5)

Cell In[29], 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[30], line 6
      3         raise ZeroDivisionError("Cannot divide by zero!")
      4     return a / b
----> 6 divide_numbers(10, 0)

Cell In[30], 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.

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[47], line 14
     11         # We can now re-raise the same exception
     12         raise
---> 14 func2(3, 0, 10)

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

Cell In[48], 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[48], line 10
      7     except ZeroDivisionError as e:
      8         raise ValueError("Some problem occurred during the calculation.")
---> 10 func2(3, 0, 10)

Cell In[48], 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.")

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[49], 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[49], 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.

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. There is another way using next.↩︎

  3. This is sometimes called asking permission↩︎

  4. Sometimes called asking forgiveness↩︎