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 went wrong in your code (dividing by zero, opening a missing file, converting "abc" to an integer), your program crashed with a red traceback.

This lesson shows you how to catch those errors, so your program can react to them instead of stopping.

This lesson covers the basics. The advanced topics (custom exceptions, exception chaining, exception groups) are in the Intermediate path.


1. The Two Kinds of "Errors"

Python has two kinds:

Syntax errors: Python can't even read (parse) your code. You see them as soon as you try to run it.

python
# print "hello"            # SyntaxError, because Python 3 needs parentheses
# if x = 5:                # SyntaxError: = assigns, == compares

Syntax errors can't be caught at runtime. Fix them in your editor.

Exceptions: your code is valid, but something went wrong while it was 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 any line in it raises an exception that matches the except, Python jumps to the except block. If nothing goes wrong, Python skips the except block.


3. Catching Specific Exceptions

Python has dozens of built-in exception types. These are the ones beginners see most often:

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. Please 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: several 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 run code after the risky code succeeded, but you don't want that code inside the try (because it can't fail in the same way). This keeps the try block short.

finally is for cleanup that must always happen: closing a file, releasing a lock, logging that the work finished.


6. Why a Bare except: Is Bad

You CAN write except: with nothing after it. Don't do it. It catches every exception, including KeyboardInterrupt (so users can't stop your program with Ctrl+C) and SystemExit (which is how sys.exit() works).

python
# DON'T DO THIS
try:
    risky()
except:                             # catches EVERYTHING, which is bad
    pass

# Slightly better: catches normal errors, but not Ctrl+C or sys.exit()
try:
    risky()
except Exception:                   # the catch-all for "normal" errors
    pass

# BEST: catch only the errors you 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

When you convert the exception to a string, you get its message. The exception object also has other attributes you can read.


8. Raising Exceptions Yourself

Sometimes you want your own code to report 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

Custom exceptions (defining your own classes, chaining, adding context to messages) are covered in the Intermediate Exceptions lesson.


9. A Real Pattern: Safe Input Loop

This is the standard pattern for "ask the user for a number, and keep asking until they give a valid one":

python
answers = ["abc", "12.5"]   # pretend the user typed these two answers, one after the other

def ask_for_number(prompt):
    """Repeatedly ask until the user enters a valid number."""
    while True:
        text = answers.pop(0)       # in real code: text = input(prompt)

        try:
            return float(text)
        except ValueError:
            print(f"'{text}' isn't a number. Try again.")

amount = ask_for_number("Enter an amount: ")
print(f"You entered {amount}")

Output:

python
'abc' isn't a number. Try again.
You entered 12.5

The first answer, "abc", raises a ValueError, so the except block prints a message and the while True loop asks again. The second answer is a valid number, so return ends the loop. In a real program, replace answers.pop(0) with input(prompt).


10. Mistakes You'll Hit

1. Bare except: (already covered, but worth repeating)
It catches Ctrl+C and sys.exit() too. You almost never want that.

2. Catching Exception and ignoring it (pass)
This hides bugs. If you catch an exception, at least report it:

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
Put only the line that can fail inside try. Everything else goes outside the try, 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: keep the try block small
try:
    data = load_file()
except FileNotFoundError:
    data = []
total = sum(data)                   # if data is bad, that error is a separate problem
+ 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 instead of an if statement when a normal if would work. Exceptions are for unusual situations.


🎯 Your Turn: A Calculator That Never Crashes

Users often type invalid input. If a program crashes on the first bad input, people
stop using it. Write safe_divide(a_text, b_text) that takes two strings and
returns either the result or a clear message. It must never raise an exception.

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: on ValueError, say which one was not a number
    # TODO 3: try the division
    # TODO 4: on 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 also hide typos in your own code, and you would never find out about them.
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 input that caused the error.
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 give two useful messages. The loop lets the message name the
input that caused the error. "Invalid input" tells the user nothing, but
"'ten' is not a number" tells them exactly what to type again. Catching only
specific exceptions also keeps your own bugs visible: a misspelled variable in
this function still raises a NameError, as it
should.


What You Learned

  • 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 keep running after bad input, missing files and invalid calculations. The next lesson, File I/O, uses these exception patterns a lot, because file operations often fail.


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 did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.