Exception Handling & Debugging
Implement try-except-finally blocks, handle custom errors, raise assertions, and debug logic flows in Python.
Downloaded from PrepIQ (https://prepiq.online)
Q1. How does try/except/else/finally work in Python?
| Block | Runs when |
|---|---|
| try | Contains code that might raise an exception |
| except | Only if an exception occurs in try |
| else | Only if NO exception occurred in try |
| finally | Always, regardless of exception or not (cleanup) |
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print('Cannot divide by zero')
return None
else:
print('Division succeeded') # only runs if no exception
return result
finally:
print('Cleanup runs always') # always runs
divide(10, 2)
# Division succeeded
# Cleanup runs always
divide(10, 0)
# Cannot divide by zero
# Cleanup runs always
# finally runs even if the function returns from inside try/except
def test():
try:
return 'from try'
finally:
print('finally still runs before returning')
test() # prints the finally message, then returns 'from try'
Q2. How do you catch multiple exceptions in Python?
# Catching multiple exception types with one handler
try:
value = int(input('Enter a number: '))
result = 10 / value
except (ValueError, ZeroDivisionError) as e:
print(f'Error occurred: {e}')
# Separate handlers for different exceptions - runs top to bottom, first match wins
try:
data = {'a': 1}
print(data['b'])
except KeyError:
print('Key not found')
except ValueError:
print('Invalid value') # not reached, KeyError matched first
except Exception as e:
print(f'Unexpected error: {e}') # catch-all, should be LAST
# Order matters - specific exceptions before general ones
try:
risky_operation()
except ZeroDivisionError:
print('Specific handler')
except Exception:
print('General handler')
# If order was reversed, Exception would catch everything first,
# making the ZeroDivisionError handler unreachable (dead code)
Q3. How do you create and raise custom exceptions in Python?
Custom exceptions are created by subclassing Exception (or a more specific built-in exception), allowing you to represent domain-specific error conditions clearly.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the account balance."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
message = f'Cannot withdraw {amount}, balance is only {balance}'
super().__init__(message)
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return self.balance
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 150, balance is only 100
print(e.balance, e.amount) # access custom attributes
# Building an exception hierarchy for a project
class AppError(Exception):
"""Base exception for the application."""
pass
class ValidationError(AppError):
pass
class NotFoundError(AppError):
pass
# Callers can catch the base class to handle ANY app-specific error
try:
raise ValidationError('Invalid email format')
except AppError as e:
print(f'App error: {e}')
Q4. What is exception chaining and what does 'raise ... from ...' do?
Exception chaining preserves the original exception's context when raising a new one in response to it, making debugging easier by showing the full causal chain.
def parse_config(raw_value):
try:
return int(raw_value)
except ValueError as e:
# 'from e' explicitly links the new exception to the original cause
raise ValueError(f'Invalid config value: {raw_value}') from e
try:
parse_config('not_a_number')
except ValueError as e:
print(e)
print(e.__cause__) # the original ValueError, accessible via __cause__
# Without 'from e' - Python still shows BOTH exceptions automatically
# (implicit chaining via __context__) but marks it differently:
# 'During handling of the above exception, another exception occurred'
def parse_config_implicit(raw_value):
try:
return int(raw_value)
except ValueError:
raise ValueError(f'Invalid config value: {raw_value}') # no 'from'
# Suppressing the chain entirely when the original isn't useful context
def parse_silent(raw_value):
try:
return int(raw_value)
except ValueError:
raise ValueError('Invalid config value') from None # hides original traceback
Q5. What is the difference between Exception and BaseException in Python?
BaseException is the root of Python's exception hierarchy. Exception is a subclass of it that covers 'normal' errors your code should typically catch, while BaseException also includes system-exiting exceptions that usually should NOT be caught broadly.
| Direct subclasses of BaseException | Purpose |
|---|---|
| Exception | Base for all 'normal' catchable errors |
| SystemExit | Raised by sys.exit() |
| KeyboardInterrupt | Raised when user presses Ctrl+C |
| GeneratorExit | Raised when a generator is closed |
# DANGEROUS - catching BaseException swallows Ctrl+C and sys.exit() too
try:
risky_operation()
except BaseException:
pass # user can no longer Ctrl+C to stop the program!
# CORRECT - catch Exception, letting system signals propagate normally
try:
risky_operation()
except Exception as e:
print(f'Handled: {e}') # KeyboardInterrupt still works as expected
# Custom exceptions should inherit from Exception, NOT BaseException
class MyAppError(Exception): # correct
pass
# class MyAppError(BaseException): # avoid - bypasses normal 'except Exception' handlers
Q6. How do context managers (the 'with' statement) relate to exception handling?
The 'with' statement guarantees cleanup code runs even if an exception occurs inside the block, using a context manager's __enter__ and __exit__ methods - similar in spirit to finally, but reusable and cleaner.
# Without 'with' - manual cleanup, easy to forget on exceptions
f = open('file.txt')
try:
data = f.read()
result = 10 / 0 # exception occurs
finally:
f.close() # must remember this every time
# With 'with' - cleanup guaranteed automatically
with open('file.txt') as f:
data = f.read()
result = 10 / 0 # exception occurs
# f.close() is called automatically even though the exception interrupted execution
# Custom context manager showing exception handling in __exit__
class Resource:
def __enter__(self):
print('Acquiring resource')
return self
def __exit__(self, exc_type, exc_value, traceback):
print('Releasing resource')
if exc_type is ValueError:
print(f'Suppressing ValueError: {exc_value}')
return True # returning True suppresses the exception
return False # returning False/None lets the exception propagate
with Resource() as r:
raise ValueError('Something went wrong')
print('Execution continues here') # reached because __exit__ suppressed it
# contextlib makes writing context managers easier with a generator
from contextlib import contextmanager
@contextmanager
def managed_resource():
print('Setup')
try:
yield 'resource'
finally:
print('Teardown') # always runs, exception or not
with managed_resource() as r:
print(f'Using {r}')
Q7. How do you use assertions for debugging in Python, and when should you avoid them?
assert statements check that a condition is true, raising an AssertionError if not. They're meant for catching programmer errors and internal invariants during development, NOT for validating user input or handling expected runtime errors.
def calculate_discount(price, discount_percent):
assert 0 <= discount_percent <= 100, 'Discount must be between 0 and 100'
return price * (1 - discount_percent / 100)
print(calculate_discount(100, 20)) # 80.0
# calculate_discount(100, 150) # AssertionError: Discount must be between 0 and 100
# DANGEROUS: using assert for input validation in production code
def withdraw(balance, amount):
assert amount <= balance, 'Insufficient funds' # BAD PRACTICE
return balance - amount
# Problem: assertions are STRIPPED OUT when Python runs with -O (optimized) flag
# python -O script.py -- all asserts are skipped entirely, no error raised!
# CORRECT: use assert for internal invariants/debugging, raise exceptions for real validation
def withdraw_safe(balance, amount):
if amount > balance:
raise ValueError('Insufficient funds') # always runs, regardless of -O flag
return balance - amount
# Good use of assert: verifying internal logic assumptions during development
def binary_search(arr, target):
assert arr == sorted(arr), 'binary_search requires a sorted array' # dev-time check
# ... search logic ...
Q8. How does Python's traceback help you debug errors?
A traceback shows the exact sequence of function calls that led to an unhandled exception, read from top (where execution started) to bottom (where the error actually occurred).
def divide(a, b):
return a / b
def calculate(x, y):
return divide(x, y)
def main():
calculate(10, 0)
main()
# Traceback (most recent call last):
# File "script.py", line 8, in <module>
# main()
# File "script.py", line 7, in main
# calculate(10, 0)
# File "script.py", line 5, in calculate
# return divide(x, y)
# File "script.py", line 2, in divide
# return a / b
# ZeroDivisionError: division by zero
# READ BOTTOM-UP: the actual error type/message is at the very bottom,
# and each frame above shows the calling function that led there
# Programmatically inspecting a traceback
import traceback
try:
calculate(10, 0)
except ZeroDivisionError:
traceback.print_exc() # prints the full traceback without stopping execution
error_string = traceback.format_exc() # get traceback as a string, e.g. for logging
# Using logging instead of print for production error tracking
import logging
logging.basicConfig(level=logging.ERROR)
try:
calculate(10, 0)
except ZeroDivisionError:
logging.exception('Calculation failed') # logs the message AND full traceback
Q9. What is the difference between logging and using print() for debugging?
| Aspect | print() | logging |
|---|---|---|
| Severity levels | No distinction | DEBUG, INFO, WARNING, ERROR, CRITICAL |
| Output control | Always prints to stdout | Configurable destinations (file, console, remote) |
| Production use | Must be manually removed | Can be turned on/off via level configuration |
| Includes context | Manual only | Automatic timestamps, module names, line numbers |
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
logger.debug('Detailed diagnostic info') # for development only
logger.info('General informational message') # normal operation events
logger.warning('Something unexpected') # potential issue
logger.error('An error occurred') # error that was handled
logger.critical('Critical failure') # severe error, app may not continue
# Setting level to WARNING hides debug/info without removing the log calls
logging.getLogger().setLevel(logging.WARNING)
logger.debug('This will NOT be shown now')
logger.warning('This WILL be shown')
# Logging exceptions with full traceback
try:
1 / 0
except ZeroDivisionError:
logger.exception('Division failed') # includes traceback automatically
# Why print() is problematic for production debugging:
# - can't easily filter by severity
# - clutters stdout, mixed with actual program output
# - no timestamps or context by default
# - must be manually found and removed before shipping
Q10. What is the difference between raise, raise Exception, and re-raising a caught exception with bare raise?
| Usage | Effect |
|---|---|
| raise SomeError('msg') | Raises a new exception with a fresh traceback starting here |
| raise (bare, inside except) | Re-raises the CURRENTLY caught exception, preserving its original traceback |
| raise from | Raises a new exception while explicitly linking to the original cause |
def process(value):
try:
return 100 / value
except ZeroDivisionError:
print('Logging the error before re-raising')
raise # bare raise - re-raises the SAME ZeroDivisionError with original traceback
try:
process(0)
except ZeroDivisionError:
print('Caught it again at a higher level')
# Common pattern: log/handle partially, then let the caller decide what to do
def risky_operation():
try:
do_something_risky()
except ValueError as e:
log_error(e)
raise # don't swallow the error, let it propagate after logging
# Contrast: creating a NEW exception loses the original traceback context
# unless you use 'raise NewError(...) from e'
def process_wrapped(value):
try:
return 100 / value
except ZeroDivisionError as e:
raise RuntimeError('Processing failed') from e # new exception, cause preserved
Exception Handling & Debugging
Implement try-except-finally blocks, handle custom errors, raise assertions, and debug logic flows in Python.
How does try/except/else/finally work in Python?
BlockRuns whentryContains code that might raise an exceptionexceptOnly if an exception occurs in tryelseOnly i...
How do you catch multiple exceptions in Python?
# Catching multiple exception types with one handler try: value = int(input('Enter a number: ')) resul...
How do you create and raise custom exceptions in Python?
Custom exceptions are created by subclassing Exception (or a more specific built-in exception), allowing you t...
What is exception chaining and what does 'raise ... from ...' do?
Exception chaining preserves the original exception's context when raising a new one in response to it, making...
What is the difference between Exception and BaseException in Python?
BaseException is the root of Python's exception hierarchy. Exception is a subclass of it that covers 'normal'...
How do context managers (the 'with' statement) relate to exception handling?
The 'with' statement guarantees cleanup code runs even if an exception occurs inside the block, using a contex...
How do you use assertions for debugging in Python, and when should you avoid them?
assert statements check that a condition is true, raising an AssertionError if not. They're meant for catching...
How does Python's traceback help you debug errors?
A traceback shows the exact sequence of function calls that led to an unhandled exception, read from top (wher...
What is the difference between logging and using print() for debugging?
Aspectprint()loggingSeverity levelsNo distinctionDEBUG, INFO, WARNING, ERROR, CRITICALOutput controlAlways pri...
What is the difference between raise, raise Exception, and re-raising a caught exception with bare raise?
UsageEffectraise SomeError('msg')Raises a new exception with a fresh traceback starting hereraise (bare, insid...