Skip to content

Python Error Handling & Debugging

Errors are a normal part of programming. This guide covers the whole cycle in four parts: reading errors, handling them so your program does not crash, raising your own when something is wrong, and debugging code to find the cause.

TaskToolExample
Understand an errorRead the traceback from the bottom upValueError: invalid literal for int()
Catch an errortry / exceptexcept ValueError:
Run only on successelseelse: print('OK')
Always clean upfinally, withwith open(path) as f:
Signal an errorraiseraise ValueError('Age cannot be negative')
Re-raisebare raiseexcept ValueError: raise
Link errorsraise ... fromraise AppError('...') from e
Check an assumptionassertassert x >= 0, 'x must be positive'
Inspect values quicklyprint() with f-stringsprint(f'{x = }')
Record what happenedlogginglogging.exception('Failed')
Pause and explorebreakpoint() (pdb)(Pdb) p x
Debug visuallyIDE debuggerBreakpoints and stepping

When an error is not handled, Python stops and prints a traceback. The error type and message are on the last line, and the lines above show the chain of calls that led to it.

traceback_example.py
def divide(a, b):
return a / b
print(divide(10, 0))
Output
Traceback (most recent call last):
File "traceback_example.py", line 4, in <module>
print(divide(10, 0))
^^^^^^^^^^^^^
File "traceback_example.py", line 2, in divide
return a / b
~~^~~
ZeroDivisionError: division by zero
  1. Read the last line first. ZeroDivisionError: division by zero tells you the type of error and what went wrong.

  2. Find where it happened. The frame closest to the bottom (line 2, in divide) is where the error was raised.

  3. Follow the calls upward. Each frame above shows who called whom, ending with the top-level call (<module>). The carets (^^^) point at the exact expression involved.

Python’s most common errors occur when Python does not understand your code (syntax) or when your logic does not match the data you are working with (everything else).

The code does not follow Python’s grammar. Python reports it before running anything.

syntax_error.py
print("Hello, world!"
# SyntaxError: '(' was never closed

The line is missing a closing parenthesis. In Python 3.9 and earlier the message read SyntaxError: unexpected EOF while parsing. The cause is the same.

Related: an IndentationError (a subclass of SyntaxError) means a block is not indented correctly.

indentation_error.py
if True:
print('Hello')
# IndentationError: expected an indented block after 'if' statement on line 1

You used a name Python cannot find. Typical causes are a typo, using a variable before defining it, or a missing import.

name_error.py
print(name)
# NameError: name 'name' is not defined

An operation was applied to a value of the wrong type.

type_error.py
5 + "5"
# TypeError: unsupported operand type(s) for +: 'int' and 'str'

You cannot add an integer and a string. More TypeError variants you will meet:

type_error_variants.py
len(5) # TypeError: object of type 'int' has no len()
'abc'() # TypeError: 'str' object is not callable
None[0] # TypeError: 'NoneType' object is not subscriptable
def greet(name):
return name
greet() # TypeError: greet() missing 1 required positional argument: 'name'
greet('a', 'b') # TypeError: greet() takes 1 positional argument but 2 were given

You asked for a position that does not exist in a sequence.

index_error.py
my_list = [1, 2, 3]
print(my_list[5])
# IndexError: list index out of range

You used a method or property that does not exist on that type of object.

attribute_error.py
num = 42
num.append(5)
# AttributeError: 'int' object has no attribute 'append'

A very common variant involves None. Methods like list.sort() return None, so chaining onto the result fails:

attribute_error_none.py
numbers = [3, 1, 2].sort()
numbers.append(4)
# AttributeError: 'NoneType' object has no attribute 'append'
ErrorTypical causeExample
ValueErrorRight type, unacceptable valueint('abc') gives invalid literal for int() with base 10: 'abc'
KeyErrorMissing dictionary key{'a': 1}['b'] gives KeyError: 'b'
ZeroDivisionErrorDividing or taking modulo by zero10 / 0 gives division by zero
UnboundLocalErrorAssigning to a name inside a function before it has a valuecounter += 1 with no global or local value
ModuleNotFoundErrorModule is not installed or is misspelledimport numpyy gives No module named 'numpyy'
ImportErrorName does not exist in the modulefrom math import foo gives cannot import name 'foo' from 'math'
FileNotFoundErrorThe path does not existopen('missing.txt') gives [Errno 2] No such file or directory: 'missing.txt'
PermissionErrorNot allowed to access the fileOpening a protected file
RecursionErrorA function calls itself without stoppingmaximum recursion depth exceeded
StopIterationnext() on an exhausted iteratornext(iter([]))
AssertionErrorAn assert condition was falseassert 1 == 2
KeyboardInterruptThe user pressed Ctrl+CStopping a running program

An exception is an error that occurs while the program runs. If nothing handles it, it travels up the call stack and ends the program with a traceback. Exception handling lets you anticipate, catch, and respond to errors in a structured way so your program does not crash unexpectedly.

basic_try_except.py
try:
x = 10 / 0
except ZeroDivisionError:
print("You can't divide by zero!")
  • try holds the code where you anticipate an error.
  • except runs if an error of the specified type is raised inside the try block.

Here the division raises a ZeroDivisionError, which is caught and handled, and the program continues.

Python gives you four blocks: try, except, else, and finally.

BlockRuns when…
tryAlways. It is where the risky code lives
exceptAn exception of the matching type was raised in try
elseNo exception was raised in try
finallyAlways, whether or not an exception occurred
try_except_else_finally.py
try:
x = 10 / 2
except ZeroDivisionError:
print("You can't divide by zero!")
else:
print('Division successful:', x) # Runs: no exception
finally:
print('This block always runs.') # Always runs

finally is useful for clean-up tasks such as closing files or releasing resources.

Code in else only runs on success, and errors raised inside it are not caught by the preceding except clauses. This keeps the try block small and focused on the one operation that can fail.

why_else.py
text = '42'
try:
number = int(text) # Only the risky line lives in try
except ValueError:
print('Not a valid number')
else:
print(number * 2) # 84, runs only if the conversion worked

Separate clauses let you respond differently to each error.

multiple_except.py
try:
number = int('abc')
result = 10 / number
except ValueError:
print('That was not a valid number.') # Runs
except ZeroDivisionError:
print("Can't divide by zero.")

Group exception types in a tuple to handle them the same way.

except_tuple.py
try:
number = int(input('Enter a number: '))
result = 10 / number
except (ValueError, ZeroDivisionError) as e:
print(f'Error occurred: {e}')

Use as to give the caught exception a name, typically e. It lets you access the actual error message for logging or debugging.

exception_object.py
try:
x = 1 / 0
except ZeroDivisionError as e:
print(f'Error occurred: {e}') # Error occurred: division by zero
exception_object_details.py
try:
int('abc')
except ValueError as e:
print(e) # invalid literal for int() with base 10: 'abc'
print(type(e).__name__) # ValueError
print(repr(e)) # ValueError("invalid literal for int() with base 10: 'abc'")
print(e.args) # ("invalid literal for int() with base 10: 'abc'",)

Exceptions are classes organized in a hierarchy. Catching a parent class also catches all of its children.

Simplified hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── AssertionError
├── AttributeError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── NameError
│ └── UnboundLocalError
├── OSError
│ ├── FileNotFoundError
│ └── PermissionError
├── RuntimeError
│ └── RecursionError
├── StopIteration
├── SyntaxError
│ └── IndentationError
├── TypeError
└── ValueError
catching_parents.py
data = {'a': 1}
items = [1, 2, 3]
for container, key in [(data, 'b'), (items, 10)]:
try:
container[key]
except LookupError as e: # Catches both KeyError and IndexError
print(f'{type(e).__name__}: {e}')
# KeyError: 'b'
# IndexError: list index out of range

Python checks except clauses top to bottom and uses the first match. Put specific exceptions before general ones, or the specific clause will never run.

except_order.py
try:
int('abc')
except Exception: # Too broad, listed first
print('General handler') # Runs
except ValueError:
print('Specific handler') # Never reached
try:
value = int(text)
except ValueError:
value = 0

Only the failure you expect is handled. Real bugs still surface.

finally runs even if the try block hits a return, break, or continue, or if an exception is not handled.

finally_with_return.py
def get_value():
try:
return 'from try'
finally:
print('cleanup runs before the function returns')
print(get_value())
# cleanup runs before the function returns
# from try

The with statement guarantees clean-up even if an error occurs, so you rarely need to write finally.
This is only used for the: objects that support the Context Manager protocol (objects that have enter and exit methods, like files, network connections, or database sessions).

with open('data.txt') as file:
content = file.read()
# The file is closed automatically, even if read() fails
safe_conversion.py
def to_int(text, default=None):
try:
return int(text)
except (ValueError, TypeError):
return default
print(to_int('42')) # 42
print(to_int('abc')) # None
print(to_int(None, 0)) # 0, int(None) raises TypeError
input_validation_loop.py
while True:
text = input('Enter a whole number: ')
try:
number = int(text)
except ValueError:
print('Not a valid number, try again.')
else:
break
print(f'You entered {number}')
collect_errors.py
records = ['10', 'x', '30', '']
numbers, errors = [], []
for record in records:
try:
numbers.append(int(record))
except ValueError:
errors.append(record)
print(numbers) # [10, 30]
print(errors) # ['x', '']
file_handling.py
try:
with open('config.txt') as f:
data = f.read()
except FileNotFoundError:
print('config.txt not found, using defaults')
data = ''
except PermissionError:
print('You do not have permission to read config.txt')
data = ''
dict_lookup.py
user = {'name': 'Ann'}
try:
email = user['email']
except KeyError:
email = 'unknown'
email = user.get('email', 'unknown') # Often simpler when a default is enough
retry.py
import time
def fetch_with_retry(action, attempts=3):
for attempt in range(1, attempts + 1):
try:
return action()
except ConnectionError as e:
print(f'Attempt {attempt} failed: {e}')
if attempt == attempts:
raise # Out of retries, let the error propagate
time.sleep(1)

contextlib.suppress states clearly that an error is expected and harmless.

suppress.py
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove('temp.txt') # No error if the file is already gone

Python programmers often attempt an operation and handle the failure, rather than checking first.

eafp.py
# Look before you leap
if 'email' in user:
email = user['email']
# Ask forgiveness (EAFP)
try:
email = user['email']
except KeyError:
email = 'unknown'

The raise statement lets you trigger an exception manually. It gives you control over when and how errors are generated, so you can create custom error conditions, signal that an error has occurred, and enforce requirements that were not met.

raise_basic.py
def check_age(age):
if age < 0:
raise ValueError('Age cannot be negative')
return age
try:
check_age(-5)
except ValueError as e:
print(f'Error: {e}') # Error: Age cannot be negative

raise is the keyword that triggers the exception. Here a ValueError is raised with a custom message when an invalid age is provided.

raise_forms.py
raise ValueError # A class: Python creates the instance for you
raise ValueError('Bad value') # An instance with a message (the usual form)
raise # Bare: re-raise the current exception (inside except only)
raise AppError('msg') from original # Chain to a cause
raise AppError('msg') from None # Suppress the original context

Using a bare raise outside an except block raises RuntimeError: No active exception to reraise.

SituationRaise
Wrong type of argumentTypeError
Right type, unacceptable valueValueError
Missing key or indexKeyError, IndexError
Feature intentionally not implementedNotImplementedError
A required file or resource is missingFileNotFoundError
Your own domain-specific problemA custom exception
validation.py
def set_discount(percent):
if not isinstance(percent, (int, float)):
raise TypeError(f'percent must be a number, got {type(percent).__name__}')
if not 0 <= percent <= 100:
raise ValueError(f'percent must be between 0 and 100, got {percent}')
return percent
set_discount('10') # TypeError: percent must be a number, got str
set_discount(150) # ValueError: percent must be between 0 and 100, got 150

A bare raise inside an except block re-raises the same exception. This lets you log or clean up while still propagating the error up the call stack.

reraise.py
def process_data(data):
try:
result = int(data)
return result * 2
except ValueError:
print('Logging: Invalid data received')
raise # Re-raises the same ValueError
try:
process_data('abc')
except ValueError:
print('Handled at higher level')

Output:

Logging: Invalid data received
Handled at higher level

Define your own exception by inheriting from Exception or one of its subclasses. Custom exceptions let you attach extra data and let callers catch exactly your errors.

custom_exception.py
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f'Insufficient funds: ${balance} available, ${amount} requested')
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
print(f'Transaction failed: {e}')
# Transaction failed: Insufficient funds: $100 available, $150 requested
print(e.balance, e.amount) # 100 150

Classes and inheritance are covered in more depth in later lessons. For now, know that this is a way to create your own exceptions with custom logic.

A shared base class lets callers catch all of your errors at once, or just one kind.

exception_hierarchy.py
class AppError(Exception):
"""Base class for every error raised by this application."""
class ValidationError(AppError):
"""Input failed validation."""
class StorageError(AppError):
"""Reading or writing data failed."""
try:
raise ValidationError('Email address is missing an @')
except AppError as e: # Catches ValidationError and StorageError
print(f'{type(e).__name__}: {e}') # ValidationError: Email address is missing an @

Use raise ... from to show how two errors relate.

exception_chaining.py
def parse_config(filename):
try:
with open(filename, 'r') as file:
data = file.read()
return int(data)
except FileNotFoundError:
raise ValueError('Configuration file is missing') from None
except ValueError as e:
raise ValueError('Invalid configuration format') from e
config = parse_config('config.txt')

If config.txt does not exist, only the new, clean error is shown:

Traceback (most recent call last):
File "main.py", line 11, in <module>
config = parse_config('config.txt')
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "main.py", line 7, in parse_config
raise ValueError('Configuration file is missing') from None
ValueError: Configuration file is missing

If the file exists but its contents are not a number, the new exception is chained to the original:

Traceback (most recent call last):
File "main.py", line 5, in parse_config
return int(data)
^^^^^^^^^
ValueError: invalid literal for int() with base 10: ''
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "main.py", line 11, in <module>
config = parse_config('config.txt')
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "main.py", line 9, in parse_config
raise ValueError('Invalid configuration format') from e
ValueError: Invalid configuration format
FormWhat the traceback shows
raise New from eBoth errors: “The above exception was the direct cause of the following exception”
raise New (inside an except)Both errors: “During handling of the above exception, another exception occurred”
raise New from NoneOnly the new error

The original exception stays available on the new one as __cause__ (for from e) or __context__ (implicit chaining).

An assert statement raises an AssertionError when its condition is false. It is essentially shorthand for a conditional raise.

assert_example.py
def calculate_square_root(number):
assert number >= 0, 'Cannot calculate square root of negative number'
return number ** 0.5
try:
result = calculate_square_root(-4)
except AssertionError as e:
print(f'Assertion failed: {e}')
# Assertion failed: Cannot calculate square root of negative number

These two blocks are equivalent:

assert_equivalent.py
assert number >= 0, 'must be non-negative'
if not number >= 0:
raise AssertionError('must be non-negative')

Strategic use of raise makes code more predictable and easier to debug. It lets you enforce business rules, validate input, and give callers clear feedback about what went wrong, instead of letting bad data travel deeper into the program and fail somewhere confusing.


Debugging is the process of identifying and resolving errors or bugs in your code. It involves examining the code, understanding the flow, and using tools to pinpoint the source of problems.

TechniqueBest for
print() and f-stringsQuick checks of values and flow
loggingPermanent diagnostic output
pdb / breakpoint()Interactive exploration in the terminal
IDE debuggerVisual inspection with breakpoints and stepping

Printing values at various points in your code helps you understand the flow of your program and the state of its variables.

print_debugging.py
def add(a, b):
result = a + b
print(f'Adding {a} and {b} gives {result}')
return result
add(2, 3) # Adding 2 and 3 gives 5

By printing a, b, and result, you can verify that the function behaves as expected. A few tricks make this faster:

print_tricks.py
x = 'hello'
items = [1, 2]
print(f'{x = }') # x = 'hello', prints the name AND the value (Python 3.8+)
print(f'{x!r}') # 'hello', repr shows quotes and hidden characters
print(f'{type(x)}') # <class 'str'>
print(f'{len(items)=}') # len(items)=2
print('DEBUG reached step 2') # Confirm that a code path actually runs

The logging module records messages with severity levels and can be switched on or off without editing your code.

logging_example.py
import logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
logging.debug('Starting calculation') # DEBUG: Starting calculation
logging.info('Processing 3 records') # INFO: Processing 3 records
logging.warning('Disk space is low') # WARNING: Disk space is low
try:
1 / 0
except ZeroDivisionError:
logging.exception('Calculation failed') # ERROR message plus the full traceback

To print a traceback without stopping the program, use the traceback module:

traceback_module.py
import traceback
try:
1 / 0
except ZeroDivisionError:
traceback.print_exc() # Prints the traceback
text = traceback.format_exc() # Or capture it as a string

Python’s built-in pdb module lets you pause the program, inspect variables, and step through code line by line.

debugging.py
import pdb
def divide(a, b):
pdb.set_trace()
return a / b
print(divide(10, 2))

By setting a trace with set_trace(), you can step through the code, inspect variables, and understand the program’s behavior.

When you run the code, the output shows the location of the file you are running, the line where you called set_trace() and the code immediately after it, and an interactive (Pdb) prompt:

> /Users/fcc/Desktop/debugging.py(5)divide()
-> return a / b
(Pdb)

Entering help at the prompt lists the commands you can use:

Full output of help
(Pdb) help
Documented commands (type help <topic>):
========================================
EOF c d h list q rv undisplay
a cl debug help ll quit s unt
alias clear disable ignore longlist r source until
args commands display interact n restart step up
b condition down j next return tbreak w
break cont enable jump p retval u whatis
bt continue exit l pp run unalias where
Miscellaneous help topics:
==========================
exec pdb

Type help <command> (for example help next) for details on any single command.

To look at the type of elements at that moment, use the whatis command:

(Pdb) whatis a
<class 'int'>
(Pdb) whatis divide
Function divide

By the time set_trace() runs, the parameter a is an integer and divide is a function.

Use continue, or its aliases cont and c, to resume the program:

(Pdb) continue
5.0
CommandAliasAction
nextnRun the current line and stop at the next one in this function
stepsRun the current line, entering any function it calls
continuec, contResume until the next breakpoint or the end
listlShow source code around the current line
longlistllShow the whole current function
print exprp exprPrint the value of an expression
pp exprPretty-print a value
whatis exprShow the type of an expression
argsaShow the arguments of the current function
wherew, btShow the call stack
up / downu / dMove up or down the call stack
break Nb NSet a breakpoint at line N
untiluntContinue until a later line is reached (useful to skip a loop)
returnrRun until the current function returns
quitqExit the debugger and stop the program
A short session
(Pdb) p a
10
(Pdb) p b
2
(Pdb) p a / b
5.0
(Pdb) n
Terminal
python -m pdb script.py

Running a script with -m pdb starts the debugger immediately. If the script crashes, pdb drops you into a post-mortem session at the line that raised, so you can inspect the variables as they were.

Many Integrated Development Environments (IDEs) offer advanced debugging tools, such as breakpoints, step execution, and variable inspection. They provide a visual interface to examine the state of your program, making it easier to identify and fix issues compared to using print statements alone.

If you use VS Code, you can set breakpoints in your code and run the debugger to pause execution at those points. Here is how to debug the same divide function.

  1. Set up your code. Create a file called main.py with the following content:

    def divide(a, b):
    result = a / b
    return result
    print(divide(10, 2))
    print(divide(15, 3))
  2. Set a breakpoint. Click in the gutter (the left margin) next to line 2 (result = a / b). A red dot appears, indicating the breakpoint is set.

  3. Start debugging. Press F5 or go to Run > Start Debugging. Select “Python File” when prompted. The debugger pauses execution at your breakpoint.

  4. Inspect variables. Hover over variables to see their current values. Use the Variables panel on the left to see all local variables. Use the Debug Console at the bottom to evaluate expressions.

  5. Step through code. Use the debug toolbar to move through the program (see the table below).

ControlShortcutAction
ContinueF5Resume execution until the next breakpoint
Step OverF10Execute the current line and move to the next
Step IntoF11Enter into function calls
Step OutShift + F11Exit the current function
  1. Reproduce the bug. Find the smallest input that reliably triggers it.

  2. Read the error. Start with the last line of the traceback, then find the line number.

  3. Form a hypothesis. Decide what you think is wrong, and what value you expect at that point.

  4. Inspect. Use a print(), breakpoint(), or IDE breakpoint to compare the actual value with your expectation. Narrow the search by checking the midpoint of the suspect code, then the half that is wrong.

  5. Fix and verify. Change one thing at a time, rerun, and confirm the bug is gone.

  6. Lock it in. Add a test (see below) so the bug cannot quietly return.

Choosing between the techniques: use print() statements for quick checks, pdb for interactive exploration, and IDE debuggers for visual inspection. Each technique has its place.

Tests can confirm that your code raises the right exception. With pytest (a third-party library):

test_check_age.py
import pytest
def check_age(age):
if age < 0:
raise ValueError('Age cannot be negative')
return age
def test_negative_age_raises():
with pytest.raises(ValueError, match='negative'):
check_age(-5)

The standard library equivalent is self.assertRaises(ValueError) inside a unittest.TestCase.


MistakeProblemFix
Bare except:Catches KeyboardInterrupt and hides bugsCatch specific exceptions
except Exception: passSilently swallows errorsHandle, log, or let it propagate
Putting many lines in tryCatches errors you did not expectKeep try to the one risky operation, use else for the rest
Broad except listed before a specific oneThe specific handler never runsOrder from specific to general
Printing an error and carrying onLoses the tracebacklogging.exception(...), or re-raise
raise e to re-raiseAdds a redundant traceback frameBare raise
return inside finallyOverrides returns and swallows exceptionsKeep finally for clean-up only
assert to validate inputRemoved when running with -OUse raise ValueError(...)
Vague messages (raise ValueError('bad'))Hard to diagnoseSay what was wrong and include the value
Using e after the except blockNameError, e is deletedCopy it to another name inside the block
Expecting else to run after an errorelse runs only when no exception occurredPut error handling in except
open() without withThe file may stay open after an errorwith open(...) as f:
Leaving debug print() calls in codeNoisy output in productionRemove them, or use logging
Chaining onto list.sort()AttributeError on NoneCall it on its own line, or use sorted()
  1. Read the last line of a traceback first, then work upward.
  2. Catch specific exceptions, and keep try blocks small.
  3. Use else for code that depends on the try succeeding and finally (or with) for clean-up.
  4. Never use a bare except:, and never swallow errors silently.
  5. Raise the most specific built-in exception that fits, and write messages that include the offending value.
  6. Use bare raise to re-raise, and raise ... from e to wrap one error in another.
  7. Define a base exception for your application and derive your custom errors from it.
  8. Validate input with raise, and keep assert for internal sanity checks.
  9. Use print() for quick checks, logging for permanent diagnostics, pdb or breakpoint() for interactive exploration, and an IDE debugger for visual stepping.
  10. After fixing a bug, add a test that reproduces it.