PythonMastery
beginner 14 min read · lesson 17 of 19 in Python Fundamentals

Errors & Exceptions: When Things Go Wrong

1 · The lesson

read

Up 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.

python
# 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.

python
# 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

python
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:

ExceptionWhen it happens
ValueErrorConversion failed: int("abc"), float("xyz")
TypeErrorWrong type for an operation: "5" + 1, calling a non-function
ZeroDivisionErrorDividing by 0
KeyErrorAsking a dict for a missing key
IndexErrorAsking a list for an out-of-range index
FileNotFoundErrorOpening a file that doesn't exist
AttributeErrorsomething.method() on an object that doesn't have it
NameErrorUsing a variable that wasn't defined
KeyboardInterruptUser pressed Ctrl+C

Always catch the most specific exception you can:

python
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:

python
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:

python
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:

python
Got 42
Cleaning up

else 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).

python
# 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:

python
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:

python
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:

python
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:

python
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:

python
# 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.

python
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:

python
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 write float(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
python
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: beats except Exception: beats except:.
  • Full shape: try / except [as e] / else / finally.
  • Use raise to 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.