Lecture 9 — Errors and Debugging#

Now that we know what kinds of errors Python can throw, how do we find and fix them? Let's talk about debugging strategies.

Lecture 9 of 18 — Progress: 50%

PyPro-SCiDaS

An Initiation to Programming using Python (Init2Py)

🐛 Lecture 9 — Errors and Debugging

Python Proficiency for Scientific Computing and Data Science

🧑‍🏫 Instructor: Yaé Gaba 📘 Course: Init2Py 📅 Date: October 2025 🎓 Semester: Semester 1, 2025–2026 ⏱️ Estimated: 50 min Intermediate
🏛️ AI Research and Innovation Nexus for Africa (AIRINA Labs), AI.Technipreneurs, Bénin
& African Center for Advanced Studies (ACAS), Cameroon
✉️ yaeulrich.gaba@gmail.com   |   🔗 LinkedIn | 🌐 Website

🎯 Learning Objectives

By the end of this lecture, you will be able to:

  • ✅ Distinguish syntax errors, runtime errors, and logical errors
  • ✅ Read and interpret Python tracebacks
  • ✅ Use try/except/finally for exception handling
  • ✅ Raise custom exceptions with raise
  • ✅ Debug code using pdb, breakpoints, and systematic techniques
So far we've been catching errors. But sometimes you want to signal that something went wrong — let's learn how to raise exceptions.

🔗 Building on What You Know

By now you've written plenty of code — and you've probably encountered cryptic red error messages. That's normal! This lecture turns those frustrating moments into learning opportunities: you'll learn to read, handle, and prevent errors like a professional developer.

Why does debugging matter?#

Unless you’re perfect, you are bound to make errors. Especially early on, expect to spend much more time debugging than actually coding. The process fits the Pareto principle - you’re going to spend ~20% of your time writing ~80% of your code, and the other ~80% of your time will be spent screaming obscenities at your computer (I think that’s what the Pareto principle says, anyway). Remember to keep calm, and LEARN from your mistakes.

Debugging is the process of detecting and correcting errors within a program.
In Python, errors generally fall into two main categories:

  • Syntax Errors: These occur when the code violates Python’s grammatical rules — for example, omitting a colon at the end of an if statement.

  • Runtime Errors: These happen while the program is executing, often caused by logical flaws or unexpected input values.

Error Types#

To err is human and Python helps us to find our mistakes by providing error type descriptions. When your code encounters an error, Python stops execution and returns an exception that describes the issue.
There are roughly 165 exceptions in the Python standard library — and you’ll encounter quite a few of them as you code.

Common Python Exceptions#

When your code encounters an error, Python stops execution and raises an exception describing the issue.
Below is a list of common exceptions, their meanings, and short examples.

Exception

Description / Common Cause

Example

SyntaxError

Code violates Python syntax rules (e.g., missing colon or parenthesis).

if True print("Hi")

IndentationError

Improper indentation or inconsistent spacing in code.

if True:\nprint("Hello")

NameError

Refers to a variable or function that hasn’t been defined.

print(a)

TypeError

Operation or function applied to an object of inappropriate type.

"text" / 5

ValueError

Function receives a valid type but invalid value.

int("abc")

IndexError

Attempt to access a list index that doesn’t exist.

my_list = [1, 2]; print(my_list[3])

KeyError

Trying to access a dictionary key that doesn’t exist.

my_dict = {"a": 1}; print(my_dict["b"])

ImportError

Attempt to import a module or name that doesn’t exist.

import non_existent_module

IOError

File or I/O operation fails (e.g., file not found).

open("missing_file.txt")

ZeroDivisionError

Division or modulo operation by zero.

5 / 0

Exception

General base class for all built-in exceptions.

raise Exception("Something went wrong")

Debugging#

Once your code becomes more complex, you’ll often face the question: Will it run?
Unfortunately, the answer is often no — but that’s where debugging comes in.
Debugging is the process of finding and fixing errors (bugs) in your code.
While debugging can seem daunting, the following principles can make it easier and more effective.

Basic Debugging Techniques#

Some common techniques to debug Python code include:

  • Print Statements: Using print() to display the values of variables at different points in the code.

  • Using a Debugger: Tools like the Python Debugger (PDB) allow you to step through the code, inspect variables, and understand the flow of execution.

  • Reading Error Messages: Python provides detailed error messages that can help pinpoint the location and cause of an error.


Use Exceptions Precisely#

Handle critical parts of your code with well-defined tryexcept blocks to manage possible errors. This helps identify where issues arise and makes your program more resilient.

🗒️ Tip: Write clear error messages inside your except blocks and document them (e.g., in Markdown).
Consider keeping a developer log or wiki that lists possible error sources and how to fix them.


Use Descriptive Variable Names#

Choose clear, meaningful names for variables, functions, and classes.
Avoid ambiguous abbreviations — only use acronyms if they’re widely understood.
For readability:

  • Use lowercase letters for variables and functions.

  • Use CamelCase for class names.


Deal with Errors#

When an error occurs, read the error message carefully — often more than once.
This helps you understand the origin of the problem.
Error messages usually specify:

  • The script or file where the issue occurred.

  • The exact line number.

  • The error type (e.g., TypeError, NameError, etc.).

If the issue isn’t clear (for example, from an external library), use a search engine or documentation to troubleshoot effectively.

Verify Output#

Just because code runs successfully doesn’t necessarily mean that the result (output) is correct.
Always validate your output using known input–output pairs — for example, compare your program’s results to a manual calculation or a verified source.
Make sure the output produced by your code matches the expected (desired) output.


Code with a Structured Approach#

Before jumping into writing code, take time to plan its structure.
Avoid immediately creating random blocks of code; instead, use structural and behavioral diagrams to organize your logic.
Concepts like UML diagrams (Unified Modeling Language) are helpful tools in software engineering for designing and visualizing your code.

✏️ Tip: Take a sheet of paper and a pencil before you start coding. Sketch how your code should work and produce the desired output.


Soft Alternatives#

When you’re stuck, try explaining your problem aloud — even to yourself.
Rephrasing or describing a problem to another person (real or imaginary) can often help you clarify your own thinking.

If frustration builds, step away from the code.
Take a walk, sleep on the problem, or do something that requires minimal concentration.
Your brain will keep processing the issue subconsciously — a phenomenon known as [incubation](https://en.wikipedia.org/wiki/Incubation_(psychology).


Raising Exceptions#

When code returns an exception, we say that the exception was thrown or raised. These exceptions may be handled or caught by the code.

Exception Handling with try - except#

try and except keywords test a code block and if it crashes, an exception is raised, but the script itself will not crash (unless otherwise defined in the except statement). The basic structure is:

   try:
    # code block
   except ErrorType:
    print("Error / warning message.")

The ErrorType is technical not necessary (i.e., except: does the job, too), but adding an ErrorType is good practice to enable efficient debugging or making other users understand the origin of an error or warning.

# Example of Raising an Exception
def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    print(f"Age: {age}")
# Handle potential errors with try/except
try:
    validate_age(5)
except ValueError as e:
    print(e)
Age: 5
# Handle potential errors with try/except
try:
    validate_age(-5)
except ValueError as e:
    print(e)
Age cannot be negative

What to do if you are unsure about the error type? Add an else statement (using exception functions such as key_not_found or handle_value):

try:
    value = a_dictionary[key]
except KeyError:
    return key_not_found(key)
else:
    return handle_value(value)

For example, suppose you’re working with a dictionary of students and their grades:

# Define the key_not_found() function
def key_not_found(key):
    print(f"⚠️ Key '{key}' not found in the dictionary!")

def handle_value(value):
    print(f"✅ Retrieved value: {value}")
students = {"Alice": 85, "Bob": 92, "Charlie": 78}

key = "David"  # This key does not exist in the dictionary

try:
    value = students[key]
except KeyError:
    key_not_found(key)
else:
    handle_value(value)
⚠️ Key 'David' not found in the dictionary!
# If you change key = "Bob", the output becomes:

key = "Bob"  # This key does not exist in the dictionary

try:
    value = students[key]
except KeyError:
    key_not_found(key)
else:
    handle_value(value)
✅ Retrieved value: 92

Custom Exceptions#

You can define custom exceptions to represent specific error conditions in your code. Custom exceptions are created by defining a new class that inherits from the built-in Exception class.

## Example of a Custom Exception
class NegativeAgeError(Exception):
    pass

def validate_age(age):
    if age < 0:
        raise NegativeAgeError("Age cannot be negative")
    print(f"Age: {age}")

try:
    validate_age(-5)
except NegativeAgeError as e:
    print(e)

Error Handling with try, except, else, finally#

Python provides a structured way to handle exceptions using the try, except, else, and finally statements:

  • try:
    Write the code that might cause an exception inside this block.

  • except:
    If an exception occurs in the try block, the code inside the except block is executed.

  • else:
    If no exception occurs, the code inside the else block runs.

  • finally:
    This block is always executed, whether or not an exception occurred.

  #Example of Error Handling
 try:
    result = 10 / 2
 except ZeroDivisionError:
    print("❌ You can't divide by zero!")
 else:
    print("✅ Division successful!")
 finally:
    print("🔁 Always executed!")

2/0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[7], line 1
----> 1 2/0

ZeroDivisionError: division by zero

🧩 Case 1: No Error Occurs#

try:
    result = 10 / 2
except ZeroDivisionError:
    print("You can't divide by zero!")
#else:
 #   print("Division successful!")
#finally:
 #   print("Always executed!")
Division successful!
Always executed!

Explanation: The division succeeds, so the else block runs, followed by the finally block.

⚠️ Case 2: Division by Zero#

try:
    result = 10 / 0
except ZeroDivisionError:
    print("You can't divide by zero!")
#else:
 #   print("Division successful!")
#finally:
 #   print("Always executed!")
You can't divide by zero!

Explanation: The ZeroDivisionError triggers the except block. The else block is skipped, but the finally block still executes.

💡 Case 3: Another Type of Error#

# Handle potential errors with try/except
try:
    result = 10 / "a"
except TypeError:
    print("You can't .....")
else:
    print("Division successful!")
finally:
    print("Always executed!")
You can't .....
Always executed!

Explanation: The error type (TypeError) doesn’t match ZeroDivisionError, so it’s not caught by the except block. The program shows an error message, but the finally block still runs.

Example: Using Multiple except Blocks with else and finally#

You can handle different types of errors separately using multiple except blocks.
This allows your program to respond appropriately depending on what kind of problem occurs.

# Handle potential errors with try/except
try:
    num1 = int(input("Enter a number: "))
    num2 = int(input("Enter another number: "))
    result = num1 / num2
except ZeroDivisionError:
    print("❌ You can't divide by zero!")
except ValueError:
    print("⚠️ Please enter valid numeric values!")
else:
    print(f"✅ Division successful! The result is {result}")
finally:
    print("🔁 Execution complete — thank you!")
✅ Division successful! The result is 0.75
🔁 Execution complete — thank you!

🧠 Practice Exercise: Exploring Exception Handling#

You’ve seen how Python uses try, except, else, and finally blocks to manage errors.
Now it’s your turn to experiment!


💻 Starter Code#

try:
    x = int(input("Enter a number: "))
    y = int(input("Enter another number: "))
    result = x / y
    print("Result:", result)
except ZeroDivisionError:
    print("❌ Division by zero is not allowed.")
except ValueError:
    print("⚠️ Please enter valid integers.")
else:
    print("✅ Division performed successfully!")
finally:
    print("🔁 End of program.")

🔍 Step 1: Try Different Cases#

Run the program several times and note what happens in each scenario:

Case

Example Input

Expected Behavior

1

10, 2

Normal division (no error) → else and finally run

2

10, 0

Division by zero → ZeroDivisionError caught

3

"ten", 5

Invalid integer → ValueError caught

4

(Try something unexpected!)

Observe what happens


🧩 Step 2: Modify the Code#

Now extend the code to handle additional error types:

  1. Add an except TypeError: block — try causing a TypeError by removing the int() conversion.

  2. Add an except Exception as e: block to catch any error not handled explicitly, and print the error message.

  3. Add a finally: message that says "This block runs no matter what!" and verify it always executes.


🧠 Step 3: Reflect#

  • Which types of errors did you observe most often?

  • What happens when you remove the except blocks?

  • Why is the finally block useful even when no errors occur?


Challenge: Can you write a version of the program that asks for input until a valid division is performed (using a while True loop with exception handling)?

=====================================================================================#

🧩 Difference Between raise and try...except in Python#

Both raise and try...except are used to deal with exceptions in Python, but they serve different purposes in how errors are created and handled.


🔹 raiseUsed to Trigger (Raise) an Exception#

The raise statement is used when you want to generate an error on purpose — either to stop the program or to signal that something went wrong.

Example:

x = -5
if x < 0:
    raise ValueError("x cannot be negative!")

Output:

ValueError: x cannot be negative!

🧩 Explanation: Here, raise is used to explicitly throw a ValueError when x is negative. It interrupts the normal flow of the program unless the error is caught.


🔹 try...exceptUsed to Catch (Handle) Exceptions#

The try...except block is used to handle errors gracefully when they occur, so the program can continue running instead of crashing.

Example:

try:
    result = 10 / 0
except ZeroDivisionError:
    print("You cannot divide by zero!")

Output:

You cannot divide by zero!

🧩 Explanation: Here, the division by zero causes an exception, but try...except catches it and prints a message instead of stopping the program.


⚖️ Comparison Summary#

Feature

raise

try...except

Purpose

Generates an exception

Handles an exception

When Used

When you want to signal an error

When you want to prevent a crash

Behavior

Stops program flow unless caught

Allows code to recover from an error

Example Use

raise ValueError("Invalid input")

try: ... except ValueError:


💡 Example Using Both Together#

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

try:
    print(divide(5, 0))
except ZeroDivisionError as e:
    print("Error caught:", e)

Output:

Error caught: Cannot divide by zero!

In short:

  • Use raise to create an error.

  • Use try...except to handle an error.


<div style="text-align: right; margin-top: 12px; font-size: 0.85em;"><a href="#table-of-contents" style="color: #667eea; text-decoration: none;">↑ Back to TOC</a></div>

The pass Statement#

When developing a program, we often deal with complex problems that cannot be solved all at once.
In such cases, Python provides the pass statement — a placeholder that allows you to create empty code blocks and build your program incrementally while debugging step by step.

Moreover, understanding different error types helps identify and fix issues in existing code.
For example, if you plan to handle a NameError later, you can temporarily use a pass statement like this:

   try:
     a = 5
     c = a + b # we want to define b later on with a complex formula
   except NameError:
     pass # we know that we did not define b yet

Controlling Exceptions %xmode#

Most Python scripts that fail do so by raising an Exception.
When this happens, Python provides detailed information about the cause of the error in a traceback, which can be viewed directly within the interpreter.

In IPython, you can customize how much information is displayed when an exception occurs by using the %xmode magic command.
This function controls the verbosity of the traceback output — from minimal to detailed — helping you better understand where and why the error happened.

Consider the following example:

# Define the func1() function
def func1(a, b):
    return a / b

def func2(x):
    a = x
    b = x - 1
    return func1(a, b)
func2(1)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[9], line 1
----> 1 func2(1)

Cell In[8], line 7, in func2(x)
      5 a = x
      6 b = x - 1
----> 7 return func1(a, b)

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

ZeroDivisionError: division by zero

When we call func2, an error occurs — and the resulting traceback shows us exactly where things went wrong.
By default, this traceback displays several lines of context for each step that led to the error.

In IPython, you can adjust the level of detail shown using the %xmode magic command (short for Exception mode).
This command takes a single argument, the display mode, which determines how much information is printed.
The available modes are:

  • Plain – provides a concise output with minimal details.

  • Context – the default mode, showing a balanced amount of contextual information.

  • Verbose – gives the most detailed traceback, including variable values and deeper context.

# Set traceback display mode
%xmode Plain
Exception reporting mode: Plain
func2(1)
Traceback (most recent call last):

  Cell In[11], line 1
    func2(1)

  Cell In[8], line 7 in func2
    return func1(a, b)

  Cell In[8], line 2 in func1
    return a / b

ZeroDivisionError: division by zero

The Verbose mode adds some extra information, including the arguments to any functions that are called:

# Set traceback display mode
%xmode Verbose
Exception reporting mode: Verbose
func2(1)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[31], line 1
----> 1 func2(1)

Cell In[26], line 7, in func2(x=1)
      5 a = x
      6 b = x - 1
----> 7 return func1(a, b)
        a = 1
        b = 0

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

ZeroDivisionError: division by zero

The additional details provided by Verbose mode can be very helpful in pinpointing the exact cause of an exception.
However, using it all the time isn’t always practical — as your code grows more complex, the traceback can become very lengthy and overwhelming.
In many cases, the shorter and cleaner output of the default Context mode is easier to read and more efficient for everyday debugging.

Debugging: When Reading Tracebacks Is Not Enough#

The primary tool for interactive debugging in Python is pdb (the Python debugger).
It allows you to step through code line by line, making it easier to identify the source of complex errors.
An improved version, ipdb, is integrated with IPython and offers a more user-friendly experience.

There are several ways to start and use these debuggers — for complete details, consult their official documentation.

In IPython, one of the most convenient ways to debug is by using the %debug magic command.
When you invoke %debug right after an exception occurs, it automatically launches an interactive debugging prompt at the point of failure.
From the ipdb interface, you can:

  • Inspect the current call stack

  • Check or modify variable values

  • Execute Python commands interactively

For example, after an exception, you can print the values of a and b, then type quit to exit the debugging session.

When propmted, type consecutively print(a), then print(b) and quit.

%debug
> c:\users\user\appdata\local\temp\ipykernel_2624\4021589855.py(2)func1()
1
0

The interactive debugger offers far more capabilities than just inspecting variables —
it allows you to navigate up and down the call stack, exploring the values of variables at each level to better understand how the program reached its current state. When propmted, type consecutively up, print(x), up, down, quit.

%debug
> c:\users\user\appdata\local\temp\ipykernel_2624\4021589855.py(2)func1()
> c:\users\user\appdata\local\temp\ipykernel_2624\4021589855.py(7)func2()
1
> c:\users\user\appdata\local\temp\ipykernel_2624\2483606204.py(1)<module>()
> c:\users\user\appdata\local\temp\ipykernel_2624\4021589855.py(7)func2()

This feature helps you identify not only what caused the error, but also the sequence of function calls that led to it.

If you want the debugger to start automatically whenever an exception occurs, you can enable this behavior using the %pdb magic command:

%xmode Plain
%pdb on
func2(1)
#When propmted, type consecutively `print(b)`, then `quit`.    
Exception reporting mode: Plain
Automatic pdb calling has been turned ON
Traceback (most recent call last):

  Cell In[36], line 3
    func2(1)

  Cell In[26], line 7 in func2
    return func1(a, b)

  Cell In[26], line 2 in func1
    return a / b

ZeroDivisionError: division by zero
> c:\users\user\appdata\local\temp\ipykernel_2624\4021589855.py(2)func1()
0

There are many more commands available for interactive debugging than the few mentioned so far.
The table below summarizes some of the most common and useful commands in pdb and ipdb:

Command

Description

list

Display the current location in the source file.

h or help

Show all available commands or get help on a specific one.

q or quit

Exit the debugger and stop program execution.

c or continue

Leave the debugger and resume normal program execution.

n or next

Move to the next line in the current function.

(Enter)

Repeat the previous command.

p or print

Display the value of a variable.

s or step

Step into a function or subroutine.

r or return

Continue execution until the current function returns.

For a full list of commands and advanced features, use the help command inside the debugger
or consult the official ipdb documentation online.

🎯 Key Takeaways

  • Python errors fall into syntax errors (code structure) and exceptions (runtime issues).
  • Common exceptions: TypeError, ValueError, IndexError, KeyError, ZeroDivisionError.
  • try/except/else/finally blocks handle errors gracefully.
  • raise lets you trigger exceptions intentionally; custom exception classes extend Exception.
  • Debugging strategies: read tracebacks carefully, use print(), use descriptive variable names.

🏁 End of Lecture 9 — Errors and Debugging

PyPro-SCiDaS • Python Proficiency for Scientific Computing and Data Science

Course progress: 56% complete (10 of 18 lectures)

© 2025 Yaé Gaba — CC BY-NC 4.0