Lesson 7

Errors & exceptions

Home › Courses › Python › Lesson 7
🐍 Lesson 7 · 15 minutes

Errors.
Read them. Catch them. Raise them.

Every programmer sees more error messages than success messages. The difference between a beginner and an experienced developer isn't fewer errors — it's reading them faster, catching them in the right places, and letting the rest crash loudly. This lesson teaches you to do all three. It's the last beginner lesson, and it's the one that changes how you write every program after it.

~15 minutes Beginner Python 3.x
Python path Lesson 6 Read tracebacks try/except raise finally Custom errors Challenge

Python errors come in two kinds: syntax errors (the code can't parse — fix immediately) and exceptions (the code parsed but failed at runtime). Handle exceptions with try/except. Catch specific types, not bare except. Use finally for cleanup. Use raise to signal failures your code detects. The best error handling is the least — let bugs crash loudly, catch only what you can actually handle.

Before this lesson: you should be comfortable with functions and files. If not, read Lesson 4 and Lesson 6 first.

🔍 Reading tracebacks — the fastest debugging skill

The error message tells you exactly what's wrong. You just have to read it correctly.

When Python hits an error, it prints a traceback — a stack of function calls leading to the failure, plus the exception type and message. Beginners often panic and skim; experienced developers read it top-to-bottom (which is rare for any document) or bottom-to-top, depending on what they need.

Traceback
Traceback (most recent call last):
 File "app.py", line 15, in 
 main()
 File "app.py", line 8, in main
 process(data)
 File "app.py", line 3, in process
 return items[10]
IndexError: list index out of range

Read the last line first

It tells you the exception type (IndexError) and message (list index out of range). That's your answer 80% of the time.

Then read the line above

It shows the exact line of code that raised the error. In the example above, return items[10]. That's where you look.

Then the file and line number

app.py, line 3 tells you where. Open that file, jump to that line. Done — you've located the bug.

The full stack is context

The lines above the exception show the chain of calls that got there. Useful for "how did we end up here?" — but usually only the last three lines matter.

Eight exception types cover 90% of what you'll see. SyntaxError — malformed code. NameError — variable doesn't exist. TypeError — wrong type for the operation. ValueError — right type, wrong value. IndexError — list index out of range. KeyError — dict key missing. AttributeError — object has no such attribute. FileNotFoundError — file missing. Learn to recognize these eight and you'll debug 90% of your own errors in seconds.

🛡️ try / except — catch what you can handle

Wrap the risky code. Catch only the errors you know how to fix.

The try/except block lets you say "I know this might fail, and here's what to do if it does." The try block contains the code that might raise. The except block contains the recovery. If no exception is raised, the except block is skipped entirely.

Python
# Basic try/except
try:
n = int(input("Enter a number: "))
print(f"Double: {n * 2}")
except ValueError:
print("That wasn't a number.")

# Catch multiple specific types
try:
data = {"name": "Alice"}
print(data["age"])
except (KeyError, IndexError) as e:
print(f"Missing: {e}")

# Multiple separate except blocks
try:
result = 10 / 0
except ZeroDivisionError:
print("Can't divide by zero")
except TypeError:
print("Wrong type")

# Access the exception object with "as e"
try:
open("missing.txt")
except FileNotFoundError as e:
print(f"Error: {e}") # [Errno 2] No such file or directory: 'missing.txt'

# Return defaults on failure (very common pattern)
def safe_int(text):
try:
return int(text)
except ValueError:
return 0

Never use bare except:. A bare except: catches everything — including KeyboardInterrupt (Ctrl+C) and SystemExit. Your user can't stop the program, and real bugs are silently swallowed. If you truly need a catch-all, use except Exception: — it catches normal errors but lets Ctrl+C work.

Keep the try block small. Put only the line that might fail inside try:. If you wrap 20 lines, you don't know which one raised — and you might catch an error from a line you didn't expect. Small try blocks are easier to debug, more honest about what can fail, and let unexpected errors crash loudly where they should.

📢 raise — signal your own errors

When your code detects a problem, raise it — don't return None or print a warning.

Sometimes your code discovers a problem that Python's built-in functions won't catch — an invalid email, a negative age, a required field that's missing. In those cases, you raise an exception yourself. This forces the caller to handle the problem instead of silently continuing with bad data.

Python
# Raise a built-in exception
def validate_age(age):
if age < 0:
raise ValueError(f"Age can't be negative: {age}")
return age

validate_age(-5)
# ValueError: Age can't be negative: -5

# The caller handles it
try:
validate_age(-5)
except ValueError as e:
print(f"Invalid input: {e}")

# Bare raise — re-raise the current exception
def load_config(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print(f"Config missing: {path}")
raise # re-raises the original, preserving traceback

# Raise from — chain exceptions for context
try:
int("not a number")
except ValueError as e:
raise RuntimeError("Config parsing failed") from e
# Traceback shows BOTH: RuntimeError caused by ValueError

Fail fast

If inputs are invalid, raise immediately. The earlier you fail, the easier the bug is to trace. Returning None and letting the caller crash later hides the origin.

Use the right type

ValueError for bad values. TypeError for wrong types. KeyError for missing keys. Match Python's conventions — your errors will look native.

Give useful messages

raise ValueError("Invalid") is useless. raise ValueError(f"Age must be 0-150, got {age}") tells the caller exactly what's wrong.

Bare raise preserves traceback

Inside except, raise alone re-raises the current exception with its original traceback. raise e also works but creates a new traceback line — minor difference, bare raise is cleaner.

raise from — when you wrap a lower-level error. If your code fails because something else failed, raise YourError(...) from original preserves both. The traceback shows "The above exception was the direct cause of the following exception" — so anyone debugging sees the chain. Use raise X from None to suppress the cause entirely (rare, but useful when the original error is noise).

🧹 else & finally — the two extras

Code that runs only on success, or always regardless of outcome.

Python
# else — runs only if try succeeded
try:
value = int("42")
except ValueError:
print("Bad input")
else:
print(f"Success: {value}") # runs only on success

# finally — runs no matter what
def process_file(path):
f = open(path)
try:
return f.read()
finally:
f.close() # ALWAYS runs, even if read() raised

# All four clauses together (rare but legal)
try:
result = risky_operation()
except ValueError as e:
print(f"Error: {e}")
except TypeError:
print("Wrong type")
else:
print(f"Got: {result}")
finally:
print("Cleanup done")

# finally runs even if try returns
def demo():
try:
return "from try"
finally:
print("finally runs") # ALWAYS

print(demo())
# finally runs
# from try

The with statement is just try/finally for files. That's why Lesson 6 insisted on with — it's literally implemented as try: ... finally: f.close() under the hood. Once you understand finally, you understand why with works. Every resource that needs cleanup can be wrapped in a context manager, not just files.

🧩 Custom exceptions

When you need the caller to distinguish your errors from built-in ones.

A ValueError from your validation function is the same type as a ValueError from int(). That makes it hard for callers to catch your errors specifically without also catching unrelated ones. Custom exceptions solve this — define a class that inherits from Exception, raise it, and the caller can catch it by name.

Python
# Define a custom exception
class ValidationError(Exception):
"""Raised when user input fails validation."""
pass

# Use it
def validate_email(email):
if "@" not in email:
raise ValidationError(f"Invalid email: {email}")
return email

# Caller can catch it specifically
try:
validate_email("not-an-email")
except ValidationError as e:
print(f"Please fix: {e}")

# Custom exception with extra data
class APIError(Exception):
def init(self, message, status_code):
super().init(message)
self.status_code = status_code

try:
raise APIError("Not found", status_code=404)
except APIError as e:
print(f"Status {e.status_code}: {e}")

# Exception hierarchy — catch a group
class AppError(Exception):
"""Base class for all app errors."""
pass

class DatabaseError(AppError):
pass

class NetworkError(AppError):
pass

# Catch all app errors with one except
try:
connect_database()
except AppError as e:
print(f"App failed: {e}") # catches DatabaseError, NetworkError, etc.

Subclass Exception

Never BaseException. Subclassing Exception means your errors play nice with except Exception: and don't break Ctrl+C handling.

One class per error type

ValidationError, AuthenticationError, RateLimitError — each a class. Callers can catch exactly what they need. One generic "AppError" for everything defeats the purpose.

Docstring the message format

If your exception carries structured data (status_code, field), document it. Callers need to know what they can rely on.

Hierarchy groups them

Make all your app's errors inherit from a base AppError. Callers who want to catch "anything from this library" catch AppError. Callers who want specificity catch ValidationError.

🔍 The mental model that makes errors click

How Python actually handles exceptions — no magic.

1

Exceptions unwind the stack

When an exception is raised, Python looks for a matching except block in the current function. If none is found, it propagates up to the caller, then its caller, and so on — this is called stack unwinding. If it reaches the top of the program with no handler, Python prints the traceback and exits.

2

The first matching handler wins

Python walks your except clauses top-to-bottom. The first one that matches the exception's type wins — the rest are skipped. Order matters: except Exception: before except ValueError: would catch everything in the first clause, making the second unreachable.

3

finally runs during unwinding

If an exception leaves a try block, the finally block runs before the exception continues propagating. Even a return inside try doesn't skip it — Python runs finally and then honors the return. This is why cleanup code goes in finally.

4

Only catch what you can handle

A function that catches an exception must either fix it, log it, or convert it to another type. If it can't do any of those, it should let the exception propagate. Catching errors just to print "something failed" is worse than not catching them — the caller gets no information and the real bug is hidden.

💻 A complete example

Error handling done right, in a small real program.

Python
# user_import.py — import users from a file with proper error handling
import json

class ValidationError(Exception):
"""Raised when a user record fails validation."""
pass

def validate_user(user):
"""Check required fields. Raise ValidationError with a useful message."""
if "name" not in user or not user["name"]:
raise ValidationError("Missing required field: name")
if "age" in user and (not isinstance(user["age"], int) or user["age"] < 0):
raise ValidationError(f"Invalid age: {user.get('age')}")
return user

def import_users(path):
"""Load users from a JSON file. Return list of valid users and a list of errors."""
try:
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except FileNotFoundError:
raise ValidationError(f"File not found: {path}")
except json.JSONDecodeError as e:
raise ValidationError(f"Invalid JSON in {path}: {e}")

valid = []
errors = []

for i, user in enumerate(raw):
try:
validate_user(user)
valid.append(user)
except ValidationError as e:
errors.append(f"Row {i}: {e}")

return valid, errors

# Use it
try:
users, errors = import_users("users.json")
print(f"Imported {len(users)} users")
for err in errors:
print(f" {err}")
except ValidationError as e:
print(f"Import failed: {e}")
finally:
print("Done.")

# Output (assuming users.json exists with 3 rows, 1 invalid):
# Imported 2 users
# Row 1: Invalid age: -5
# Done.

Notice the pattern: fail the whole import, or skip individual rows? File-level failures (missing file, corrupt JSON) are fatal — the whole import can't proceed. Row-level failures (one bad user) shouldn't stop the rest. This is a real architectural decision every data-import system makes, and this example shows both. Catch at the right scope.

🏆 Challenge

Three tasks. Each is a different error pattern.

Task 1 — Safe division

Write a function safe_divide(a, b) that returns a / b if possible, or the string "undefined" if b is zero. If a or b isn't a number, return "invalid input".

Test: safe_divide(10, 2) → 5.0. safe_divide(10, 0) → "undefined". safe_divide("ten", 2) → "invalid input". Use except (ZeroDivisionError, TypeError).

Task 2 — Retry with backoff

Write a function retry(func, attempts=3) that calls func up to attempts times. If it succeeds, return the result. If it raises an exception, wait 1 second and try again. After attempts failures, raise the last exception. Use try/except and the time.sleep() function.

Test with a function that fails the first two times then succeeds. It should print "retry" twice and then return.

Task 3 — Custom validation

Define a ValidationError exception (subclass of Exception). Write a function validate_password(password) that raises ValidationError with a specific message if: password is shorter than 8 characters, or contains no digit, or contains no uppercase letter. Return True if valid.

Test with "abc" (too short), "abcdefgh" (no digit), "abcdefg1" (no uppercase), "Abcdefg1" (valid). Catch each error and print the specific message.

⚠️ Common mistakes

The error-handling bugs that turn bugs into mysteries.

1. Bare except

except: catches everything — including Ctrl+C. Use except Exception: for true catch-alls, or better, catch specific types. Bare except makes Ctrl+C not work and silently hides real bugs.

2. Swallowing errors with pass

except: pass means "I saw a bug and decided to hide it." The program continues with bad data, and the failure shows up 100 lines later as something unrelated. Log it, re-raise it, or handle it — never silence it.

3. Wrapping everything in try

If your entire main() is inside one try/except, you don't know what failed. The except block can't recover meaningfully because it doesn't know which line raised. Wrap only the specific risk.

4. Catching too broad a type

except Exception catches TypeError, ValueError, AttributeError — including bugs from typos. Catch ValueError specifically. If you catch broadly, you catch errors that should have crashed loudly.

5. Forgetting to re-raise

You catch an exception to log it, then forget raise. The caller thinks the operation succeeded. Fix: after logging, raise to re-raise the same exception. This preserves the traceback.

6. Using return in finally

A return inside finally silently overrides any return from try or exception propagation. finally should clean up, not decide outcomes. Save return logic for the end of the function.

7. Redefining built-in exceptions

class ValueError(Exception): shadows the built-in ValueError in your module. Any code below that imports it will get your version, not Python's. Name custom exceptions with unique names (e.g., ValidationError, not ValueError).

8. Using assertions for validation

assert age > 0 is a debugging tool, not validation. It's stripped out in optimized mode (python -O), so any code relying on it breaks in production. Use raise ValueError(...) for real validation.

🎓 You finished the beginner tier

Seven lessons. Real Python. Here's what you can do now.

You can read Python

Variables, lists, dicts, loops, functions, files, errors — no concept in beginner Python will surprise you now. Reading someone else's code is possible.

You can write scripts

Automation, file processing, data analysis — the scripts people pay $500 for on Upwork are within reach. Build one to prove it to yourself.

You can debug

Read the traceback, locate the line, fix the code. That's the entire debugging loop, and you now have the tools for every part of it.

You can learn faster

The next language, framework, or library will be 5x easier. You're not learning what a variable is anymore — you're mapping patterns to new syntax.

What to build before the intermediate tier. A CLI tool that does one useful thing — a file organizer that sorts by extension, a log analyzer that counts errors per hour, a markdown-to-HTML converter, a csv-to-json transformer. Build one end-to-end, put it on GitHub, and you've gone from "learning Python" to "using Python." That's the real milestone.

➡️ What's next

The intermediate tier opens up — classes, modules, and real projects.

❓ Frequently asked questions

How do I handle errors in Python?

Wrap the risky code in try and catch it with except: try: risky() except ValueError: handle_error(). Catch specific exception types, not bare except. To run cleanup code regardless of outcome, add a finally block. To raise your own errors, use raise ExceptionType('message'). The pattern is try / except / else / finally — but you rarely need all four.

What is the difference between SyntaxError and Exception?

SyntaxError happens at parse time — the code can't even start running because it's malformed (missing colon, unmatched parenthesis). It cannot be caught by try/except in the same file. All other exceptions (ValueError, KeyError, TypeError) happen at runtime and can be caught. If you see SyntaxError, fix the code — don't try to catch it.

Should I use bare except: in Python?

Almost never. A bare except catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit — so users can't stop your program. It also hides real bugs. Catch specific exceptions instead: except ValueError: or except (ValueError, KeyError):. The only valid use is a top-level catch-all in a long-running server that logs the error and continues.

What is the difference between except Exception and bare except?

except Exception: catches all normal exceptions (everything that inherits from Exception) but not KeyboardInterrupt or SystemExit, which inherit from BaseException. Bare except: catches literally everything including those two. Use except Exception: when you truly need a catch-all — it's much safer than bare except and lets Ctrl+C work.

What does the else clause in try/except do?

Code in else runs only if no exception was raised in the try block. It's a way to separate 'this is the risky part' from 'this is what to do when it succeeds.' Try to keep the try block as small as possible — move successful-path logic to else. This prevents accidentally catching errors from code that shouldn't be inside try.

What does finally do in Python?

finally runs no matter what — whether the try succeeded, failed, or raised something you didn't catch. It's for cleanup: closing files, releasing locks, disconnecting from databases. Even a return statement inside try doesn't skip finally. The with statement is built on this — it's finally that guarantees the file closes.

How do I create a custom exception in Python?

Subclass Exception: class ValidationError(Exception): pass. Then raise it like any other: raise ValidationError('Email is invalid'). Give it a docstring and, if you need extra fields, override init. Custom exceptions make code more readable — callers can catch ValidationError specifically instead of guessing at ValueError. Subclass from Exception, not BaseException.

What does 'raise X from Y' do in Python?

It chains exceptions. raise NewError('...') from original_error sets cause on the new exception, so the traceback shows 'The above exception was the direct cause of the following exception' and preserves both. Use it when catching an error and re-raising a different type — the caller sees your clean error, but the original cause is still visible in the traceback. Bare raise re-raises the current exception; raise X from None suppresses the chain.

🌐 External resources

← Back to Python track
You're in the Python track · Lesson 7 · Beginner tier complete