Errors & Exceptions: When Things Go Wrong
1 · The lesson
readUp to now, when something goes wrong in your code — dividing by zero, opening a missing file, converting "abc" to an integer — your program crashes with a red traceback.
This lesson teaches you how to catch those errors gracefully so your program can react to them, instead of dying.
This is the basic version of the concept. The full advanced treatment (custom exceptions, exception chaining, contextual exception groups) lives in the Intermediate path.
1. The Two Kinds of "Errors"
Python distinguishes two:
Syntax errors — Python can't even parse your code. You see them as soon as you try to run.
# print "hello" # SyntaxError — Python 3 needs parentheses # if x = 5: # SyntaxError — that's = not ==
Syntax errors can't be caught at runtime. Fix them in your editor.
Exceptions — your code is valid but something went wrong while running.
# print(10 / 0) # ZeroDivisionError # print(int("abc")) # ValueError # print(undefined_var) # NameError
Exceptions CAN be caught. That's what this lesson is about.
2. try / except — The Basic Shape
try: # code that might fail result = 10 / 0 except ZeroDivisionError: # what to do if it fails print("Can't divide by zero — using 1 instead") result = 10 / 1 print(f"Result: {result}") # Result: 10.0
The try block holds the risky code. If anything in it raises an exception matching the except, control jumps to the except block. If nothing goes wrong, the except block is skipped entirely.
3. Catching Specific Exceptions
Python has dozens of built-in exception types. The most common ones a beginner hits:
| Exception | When it happens |
|---|---|
ValueError | Conversion failed: int("abc"), float("xyz") |
TypeError | Wrong type for an operation: "5" + 1, calling a non-function |
ZeroDivisionError | Dividing by 0 |
KeyError | Asking a dict for a missing key |
IndexError | Asking a list for an out-of-range index |
FileNotFoundError | Opening a file that doesn't exist |
AttributeError | something.method() on an object that doesn't have it |
NameError | Using a variable that wasn't defined |
KeyboardInterrupt | User pressed Ctrl+C |
Always catch the most specific exception you can:
user_input = "not a number" try: n = int(user_input) print(f"You entered {n}") except ValueError: print("That wasn't a valid number — try again")
4. Catching Multiple Exception Types
You can list several exception types in one except, or have multiple except blocks:
data = {"name": "Alice"}
# Style A — multiple types in one tuple
try:
print(data["age"]) # KeyError
except (KeyError, IndexError):
print("Couldn't find that")
# Style B — different actions for different errors
try:
age = int(data["age"])
except KeyError:
print("No age field")
except ValueError:
print("Age wasn't a valid number")Use style B when each error needs a different response, style A when they share one.
5. The else and finally Clauses
The full shape of try/except has TWO extra clauses most beginners don't know about:
try: n = int("42") except ValueError: print("Conversion failed") else: # runs ONLY if no exception was raised print(f"Got {n}") finally: # ALWAYS runs, success or failure print("Cleaning up")
Output:
Got 42
Cleaning upelse is useful when you want to do something after the risky code succeeded, but you don't want it inside the try (because it can't fail in the same way). Keeps the try block focused.
finally is for cleanup that must happen no matter what — closing a file, releasing a lock, logging completion.
6. The Sin of Bare except:
You CAN write except: with nothing after it. Don't. It catches absolutely everything — including KeyboardInterrupt (so users can't Ctrl+C your program) and SystemExit (which is how sys.exit() works).
# DON'T DO THIS try: risky() except: # catches EVERYTHING — bad pass # Slightly better — catches all exceptions but not system-exit-y stuff try: risky() except Exception: # the catch-all for "normal" errors pass # BEST — catch what you actually expect try: risky() except ValueError: handle_bad_value()
setup added so this can run · defines risky, handle_bad_value
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()') def handle_bad_value(*_a, **_kw): print('-> handle_bad_value() called') return _AutoMock('handle_bad_value()')
If you don't know what exception to expect, run the code without try/except first, see what it raises, then catch that specifically.
7. Reading the Exception — as e
You can capture the exception object to inspect it:
try: int("abc") except ValueError as e: print(f"Sorry, {e}") # Sorry, invalid literal for int() with base 10: 'abc' print(f"Error type: {type(e).__name__}") # ValueError
The exception has a string representation (the human message) and other attributes you can read.
8. Raising Exceptions Yourself
Sometimes you want your own code to signal an error. Use raise:
def get_age(age): if age < 0: raise ValueError(f"Age can't be negative, got {age}") if age > 150: raise ValueError(f"That's not a realistic age: {age}") return age try: get_age(-5) except ValueError as e: print(f"Bad age: {e}") # Bad age: Age can't be negative, got -5
The full power of custom exceptions (defining your own classes, chaining, contextual messages) lives in the Intermediate Exceptions lesson.
9. A Real Pattern — Safe Input Loop
This is the canonical "ask the user for a number, keep asking until they give a valid one" pattern:
def ask_for_number(prompt): """Repeatedly ask until the user enters a valid number.""" while True: # (simulated for the browser — in real code: text = input(prompt)) text = "12.5" # pretend this came from input() try: return float(text) except ValueError: print(f"'{text}' isn't a number. Try again.") return None # in real code, the while loop would continue amount = ask_for_number("Enter an amount: ") print(f"You entered {amount}")
In a real terminal program, the while True keeps looping until the user enters something valid. In the browser sandbox we simulate one iteration.
10. Mistakes You'll Hit
1. Bare except: (already covered, but worth repeating)
Catches keyboard interrupts and exits. Almost never what you want.
2. Catching Exception and ignoring it (pass)
This hides bugs. If you catch, at least log:
try: risky() except Exception as e: print(f"Warning: {e}") # at least say something
setup added so this can run · defines risky
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()')
3. Putting too much in the try block
Only put the line that can actually fail inside try. Everything else goes outside or in else:
# BAD — both lines can fail in different ways try: data = load_file() total = sum(data) except: # which one failed? pass # GOOD — narrow the try try: data = load_file() except FileNotFoundError: data = [] total = sum(data) # if data is bad, error here is a separate concern
setup added so this can run · defines load_file
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def load_file(*_a, **_kw): print('-> load_file() called') return _AutoMock('load_file()')
4. Using exceptions for normal flow control
Don't use try/except as "if statement" when a regular if would do. Exceptions are for exceptional situations.
🎯 Your Turn — A Calculator That Never Crashes
Users type nonsense. A program that dies on the first bad input is a program
nobody uses twice. Write safe_divide(a_text, b_text) that takes two strings and
returns either the result or a clear, human message — but never raises.
safe_divide("10", "4") → 2.5 safe_divide("10", "0") → "Cannot divide by zero." safe_divide("ten", "2") → "'ten' is not a number." safe_divide("10", "two") → "'two' is not a number."
Skeleton:
def safe_divide(a_text, b_text): # TODO 1: try converting BOTH strings to float # TODO 2: except ValueError -> say which one was not a number # TODO 3: try the division # TODO 4: except ZeroDivisionError -> return the friendly message ... print(safe_divide("10", "4")) # 2.5 print(safe_divide("10", "0")) # Cannot divide by zero. print(safe_divide("ten", "2")) # 'ten' is not a number.
Hint 1 — Catch the specific exception, not everything
except ValueError catches a failed float() conversion.
except ZeroDivisionError catches division by zero. A bare
except: would swallow typos in your own code too, and you would
never find out.
Hint 2 — Convert one at a time to know which one failed
If you writefloat(a_text), float(b_text) in one line you know that
something failed but not which. Convert them in separate steps, or loop over
the two values, so your message can name the offending input.
Show full solution
def safe_divide(a_text, b_text): numbers = [] for text in (a_text, b_text): try: numbers.append(float(text)) except ValueError: return f"'{text}' is not a number." try: return numbers[0] / numbers[1] except ZeroDivisionError: return "Cannot divide by zero." print(safe_divide("10", "4")) # 2.5 print(safe_divide("10", "0")) # Cannot divide by zero. print(safe_divide("ten", "2")) # 'ten' is not a number. print(safe_divide("10", "two")) # 'two' is not a number.
Two named exceptions, two useful messages. The loop lets the error name the
input that caused it, which is the difference between "invalid input" — which
tells the user nothing — and "'ten' is not a number", which tells them exactly
what to retype. Catching narrowly is what keeps your own bugs visible: a
misspelled variable in this function still raises NameError loudly, as it
should.
Recap
- Syntax errors = code won't parse. Fix in editor.
- Exceptions = code parses but fails at runtime. Catch with
try/except. - Catch the most specific exception type.
except ValueError:beatsexcept Exception:beatsexcept:. - Full shape:
try / except [as e] / else / finally. - Use
raiseto signal errors in your own code. - Never use bare
except:. - Don't catch errors just to ignore them silently.
You can now write programs that survive bad input, missing files, and bad math. The next lesson — File I/O — uses these exception patterns constantly, because file operations fail all the time.
Source: adapted from Python official documentation Section 8 (Errors and Exceptions). PSF License.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.