Skip to main content

Catching Errors with try/except

James writes a function that converts a word count string to an integer. He calls int("350") and gets 350. He calls int("many") and Python crashes with a ValueError. The program stops. Every line after the crash never runs.

"You saw this coming," Emma says. "Back in Chapter 47, Lesson 4, you discovered that int('hello') raises a ValueError. You knew the crash would happen. Now you learn to catch it."

James nods. He also remembers Chapter 52, Lesson 4, where he tested that functions raise the right exceptions using pytest.raises. He was testing other people's error handling. Now he writes his own.

If you're new to programming

An exception is Python's way of saying "something went wrong." Instead of returning a bad value or silently continuing, Python stops the program and reports what happened. Error handling lets you intercept that report and decide what to do instead of crashing.

If you know exceptions from another language

Python's try/except is equivalent to try/catch in Java, C#, or JavaScript. Python uses except instead of catch, and exception types are classes (not interfaces). The else clause on a try block is less common in other languages; it runs only when no exception was raised.


The Problem: Unhandled Exceptions

When Python encounters an error it cannot resolve, it raises an exception. If nothing catches that exception, the program crashes:

word_count_str: str = "many"
word_count: int = int(word_count_str) # Crashes here
print(f"Word count: {word_count}") # Never runs

Output:

ValueError: invalid literal for int() with base 10: 'many'

The print line never executes. Python stops at the crash and shows a traceback. For a script processing 100 notes, one bad value in note 11 means notes 12 through 100 are never processed.


Your First try/except

A try/except block lets you catch the exception and handle it instead of crashing:

word_count_str: str = "many"

try:
word_count: int = int(word_count_str)
print(f"Word count: {word_count}")
except ValueError:
print("Could not convert to integer.")

Output:

Could not convert to integer.

Python tries to run the code inside the try block. When int("many") raises a ValueError, Python jumps to the except ValueError block and runs that code instead. The program continues normally after the except block.


Inspecting the Error with as e

You can capture the exception object to see what went wrong. The as keyword assigns the exception to a variable:

word_count_str: str = "many"

try:
word_count: int = int(word_count_str)
except ValueError as e:
print(f"Error: {e}")

Output:

Error: invalid literal for int() with base 10: 'many'

The variable e holds the exception object. Converting it to a string with f"{e}" gives the error message. This is useful for logging: instead of a generic "something went wrong," you get the specific reason.


The else Clause: Success Path

What if you want to run code only when no exception occurred? You could put it inside the try block, but that risks catching exceptions from lines that should not be caught. The else clause runs only when the try block succeeds:

def parse_word_count(raw: str) -> int:
"""Parse a word count string into an integer.

Returns the parsed integer, or -1 if parsing fails.
"""
try:
value: int = int(raw)
except ValueError as e:
print(f"Bad value: {e}")
return -1
else:
print(f"Parsed successfully: {value}")
return value
result_good: int = parse_word_count("350")
# Output: Parsed successfully: 350

result_bad: int = parse_word_count("many")
# Output: Bad value: invalid literal for int() with base 10: 'many'

The else block runs after try succeeds, before execution moves past the whole try/except structure. This keeps the success logic cleanly separated from the error logic.


Testing Both Paths

Every error-handling function has at least two paths: the success path and the error path. Test both:

def safe_int(raw: str) -> int:
"""Convert string to int, raising ValueError for non-numeric input."""
try:
return int(raw)
except ValueError:
raise ValueError(f"Cannot convert '{raw}' to integer")


def test_safe_int_valid() -> None:
assert safe_int("42") == 42


def test_safe_int_negative() -> None:
assert safe_int("-7") == -7


def test_safe_int_invalid() -> None:
import pytest
with pytest.raises(ValueError):
safe_int("hello")

Three tests, two paths. The first two test the success path with different valid inputs. The third tests the error path by passing invalid input and confirming a ValueError is raised. You learned pytest.raises in Chapter 52. Now you are using it to test your own error handling.


The Danger of Bare except:

A bare except: clause (no exception type specified) catches everything, including things you should never catch:

# DO NOT DO THIS
try:
value: int = int(input_str)
except: # Catches EVERYTHING
print("Something went wrong")

This catches ValueError (which you want), but it also catches KeyboardInterrupt (Ctrl+C), SystemExit, and MemoryError. If a user presses Ctrl+C to stop a runaway program, a bare except swallows that signal and keeps running. Always specify the exception type:

# DO THIS INSTEAD
try:
value: int = int(input_str)
except ValueError:
print("Not a valid integer")

If you genuinely need to catch a broad range of errors (which is rare at this stage), use except Exception instead of bare except. You will learn why in Lesson 3.


Catching Multiple Exception Types

Sometimes a block of code can raise different types of exceptions. You can handle each one separately:

def get_note_field(note: dict[str, str], field: str) -> str:
"""Get a field from a note dictionary.

Raises KeyError if field is missing.
Raises TypeError if note is not a dictionary.
"""
try:
return note[field]
except KeyError:
print(f"Field '{field}' not found in note")
raise
except TypeError:
print("Expected a dictionary, got something else")
raise

Python checks except clauses top to bottom and runs the first one that matches. If note is None, accessing None[field] raises a TypeError, so the second block runs. If note is a valid dict but field is missing, a KeyError is raised and the first block runs.


PRIMM-AI+ Practice: Catching Exceptions

Predict [AI-FREE]

Look at this code without running it. For each scenario, predict what prints. Write your predictions and a confidence score from 1 to 5 before checking.

def convert_temperature(raw: str) -> float:
try:
celsius: float = float(raw)
except ValueError as e:
print(f"CAUGHT: {e}")
return 0.0
else:
print(f"SUCCESS: {celsius}")
return celsius

# Scenario 1
result1: float = convert_temperature("23.5")

# Scenario 2
result2: float = convert_temperature("hot")

# Scenario 3
result3: float = convert_temperature("")
Check your predictions

Scenario 1: Prints SUCCESS: 23.5. The float("23.5") succeeds, so else runs. Returns 23.5.

Scenario 2: Prints CAUGHT: could not convert string to float: 'hot'. The float("hot") raises ValueError, so except runs. Returns 0.0.

Scenario 3: Prints CAUGHT: could not convert string to float: ''. The float("") raises ValueError, so except runs. Returns 0.0.

If you predicted all three correctly, your try/except intuition is solid. If you missed Scenario 3 (empty string also fails), note that float("") is not the same as float("0").

Run

Create a file called try_except_practice.py with the convert_temperature function. Add the three calls at the bottom. Run uv run python try_except_practice.py. Compare the output to your predictions.

Investigate

Add a fourth scenario: convert_temperature(" 42 ") (spaces around the number). Before running, predict whether float handles the spaces. After running, explain why it does or does not raise an exception.

Modify

Change the except clause from except ValueError to except TypeError. Now call convert_temperature("hot"). What happens? Why does the program crash instead of catching the error?

Hint

float("hot") raises ValueError, not TypeError. If your except clause catches TypeError, the ValueError is not caught and the program crashes. The exception type in the except clause must match the type that Python actually raises.

Make [Mastery Gate]

Without looking at any examples, write a function called parse_score(raw: str) -> int that:

  • Tries to convert raw to an integer
  • Returns the integer if successful
  • Raises ValueError with the message f"Invalid score: '{raw}'" if conversion fails

Then write three tests:

  1. test_parse_score_valid: verifies parse_score("85") returns 85
  2. test_parse_score_zero: verifies parse_score("0") returns 0
  3. test_parse_score_invalid: verifies parse_score("abc") raises ValueError

Run uv run pytest to verify all tests pass.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Check Your Understanding

I just learned about try/except in Python. Here is my
understanding: "The else clause in a try/except block runs
when the except block finishes." Is my summary accurate?
What am I getting wrong?

Read the AI's response carefully. Did it correct the claim? The else clause runs when the try block finishes without an exception, not when the except block finishes. Compare its explanation to what you learned in this lesson.

Prompt 2: Generate and Review

Write a Python function called safe_divide that takes two
float parameters (numerator and denominator) and returns
their quotient. Handle ZeroDivisionError. Use type
annotations on all parameters and the return type.
Include a docstring.

Review the AI's output. Check: does it catch ZeroDivisionError specifically (not bare except)? Does it have a return type annotation? Does the docstring mention the error case? If anything looks wrong, tell the AI what to fix.

What you're learning: You are applying the "specific except" rule from this lesson to evaluate AI-generated code.

Prompt 3: Generate Tests

Write pytest tests for the safe_divide function you just
created. Include a test for the success path, a test for
the zero denominator path, and a test for negative numbers.
Use pytest.raises where appropriate.

Review the tests. Do they cover both the success and error paths? Does the pytest.raises test pass the right exception type? Run the tests to verify they pass.

What you're learning: You are connecting error handling (this lesson) with testing (Chapter 52) by reviewing AI-generated test code.


Key Takeaways

  1. try/except catches exceptions instead of crashing. Python runs the try block normally. If an exception is raised, it jumps to the matching except block.

  2. as e lets you inspect the error. The variable e holds the exception object. Use f"{e}" to get the error message for logging or re-raising.

  3. else runs only on success. Code in the else block executes only when no exception was raised in the try block. This keeps success logic separate from error logic.

  4. Always specify the exception type. A bare except: catches everything, including Ctrl+C and system exits. Use except ValueError: or except TypeError: to catch only what you intend.

  5. Test every path. A function with try/except has at least two paths (success and failure). Write a test for each path. Use pytest.raises from Chapter 52 for the error path.


Looking Ahead

You can now catch exceptions that Python raises. In Lesson 2, you will learn to raise your own exceptions. Instead of returning None or -1 when something goes wrong, you will signal errors with clear, descriptive messages that callers can catch and handle.