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.
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 (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.
# 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.
# 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.
# 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.
# 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.
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.
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.
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.
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.
# 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.
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.
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.
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.
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.
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.
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.
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).
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.