Lecture 9 — Errors and Debugging#
🎯 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/finallyfor exception handling - ✅ Raise custom exceptions with
raise - ✅ Debug code using
pdb, breakpoints, and systematic techniques
📑 Table of Contents
- ● 🐛 Lecture 9 — Errors and Debugging
- 1. ● Why does debugging matter?
- 2. ● Error Types
- 3. ● Debugging
- 4. ● Verify Output
- 5. ● Code with a Structured Approach
- 6. ● Soft Alternatives
- 7. ● Raising Exceptions
- 8. ● Example of a Custom Exception
- 9. ● The `pass` Statement
- 10. ● Controlling Exceptions ` %xmode`
- 11. ● Debugging: When Reading Tracebacks Is Not Enough
🔗 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
ifstatement.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 |
|---|---|---|
|
Code violates Python syntax rules (e.g., missing colon or parenthesis). |
|
|
Improper indentation or inconsistent spacing in code. |
|
|
Refers to a variable or function that hasn’t been defined. |
|
|
Operation or function applied to an object of inappropriate type. |
|
|
Function receives a valid type but invalid value. |
|
|
Attempt to access a list index that doesn’t exist. |
|
|
Trying to access a dictionary key that doesn’t exist. |
|
|
Attempt to import a module or name that doesn’t exist. |
|
|
File or I/O operation fails (e.g., file not found). |
|
|
Division or modulo operation by zero. |
|
|
General base class for all built-in exceptions. |
|
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 try–except blocks to manage possible errors. This helps identify where issues arise and makes your program more resilient.
🗒️ Tip: Write clear error messages inside your
exceptblocks 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 thetryblock, the code inside theexceptblock is executed.else:
If no exception occurs, the code inside theelseblock 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 |
|
Normal division (no error) → |
2 |
|
Division by zero → |
3 |
|
Invalid integer → |
4 |
(Try something unexpected!) |
Observe what happens |
🧩 Step 2: Modify the Code#
Now extend the code to handle additional error types:
Add an
except TypeError:block — try causing aTypeErrorby removing theint()conversion.Add an
except Exception as e:block to catch any error not handled explicitly, and print the error message.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
exceptblocks?Why is the
finallyblock 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.
🔹 raise — Used 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...except — Used 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 |
|
|
|---|---|---|
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 |
|
|
💡 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
raiseto create an error.Use
try...exceptto 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 |
|---|---|
|
Display the current location in the source file. |
|
Show all available commands or get help on a specific one. |
|
Exit the debugger and stop program execution. |
|
Leave the debugger and resume normal program execution. |
|
Move to the next line in the current function. |
(Enter) |
Repeat the previous command. |
|
Display the value of a variable. |
|
Step into a function or subroutine. |
|
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/finallyblocks handle errors gracefully.raiselets you trigger exceptions intentionally; custom exception classes extendException.- Debugging strategies: read tracebacks carefully, use
print(), use descriptive variable names.
—
—