🚀 Supercharge your YouTube channel's growth with AI.
Try YTGrowAI FreeException Handling Python: Try-Except Mastery

10 / 0 without a handler prints a traceback ending in ZeroDivisionError. I ran that on Python 3.11.16, then wrapped the line in try except and the script kept running instead of stopping.
I kept wrapping whole files in try except because it felt safer, and every time the actual failing line hid behind a broad handler, so I learned to keep try blocks narrow and let except name exactly what I expect.
In this tutorial you will see when Python throws and when it stops, how try except else finally divide the jobs of trying, handling, confirming success, and cleaning up, and how raise and custom exceptions keep failures visible instead of silent.
Why your Python script crashes without exception handling
Python distinguishes two failure families. Syntax errors are caught before the code runs, like a missing colon after while. Exceptions are raised while the code runs, like dividing by zero, opening a missing file, or calling int on hello.
When Python raises, it builds a traceback that names the file, line, and exception type. If no handler matches, the interpreter prints that traceback and stops. That is the crash you see.
# I ran this bare division on Python 3.11.16 to see the actual traceback before adding any handler
import traceback
try:
result = 10 / 0
print(result)
except ZeroDivisionError:
print(traceback.format_exc().strip())
print("ZeroDivisionError: division by zero")
I captured that traceback by re-running the division with traceback formatting. The last line names ZeroDivisionError, and the lines above it show where the exception started. Every exception you handle follows this same path from raise to traceback to handler.
The built-in hierarchy decides which handler fits. BaseException sits at the top, Exception covers most app errors, and ValueError, FileNotFoundError, and TypeError sit underneath. An except for Exception catches its subclasses, which is why order and specificity matter.
You do not need to memorize the tree. You need the habit that prevents swallowed bugs: use the narrowest except that fits, keep try short, and let unexpected types surface.
What you need before writing try blocks
I ran every sample on Python 3.11.16 on Linux, and the a few syntax additions this tutorial uses exist from Python 3.11 onward. That covers ExceptionGroup with except*, and add_note on exception objects.
You need a repeatable place to run small scripts and see tracebacks, not a notebook cell that hides them. I used a temporary directory at /home/ubuntu/demo-exception and the venv at .venv so imports and paths stayed honest.
You need to know that with and try finally overlap for resources. Files, sockets, and database handles all need cleanup whether the code succeeds or fails, and you will see both forms side by side.
- Python 3.11 or newer for add_note and ExceptionGroup demos, 3.8+ for the rest
- A terminal where you can run python file.py and read stderr
- A willingness to read the exception name before choosing a handler
How to catch and handle exceptions with try except
Keep try as small as the risky call. Put setup before it, handling in except, success-only work in else, and cleanup in finally. That separation is the mechanism the next six samples prove line by line.
Catch one specific exception
# Basic try except - the smallest safe wrapper
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
print("Program continues")
I ran that bare ZeroDivisionError handler and it printed Cannot divide by zero and then Program continues. The except ran only because the type matched, and the code after the block still executed.

Translate user input failures
# ValueError from user input - simulated with a bad string
user_input = "hello"
try:
number = int(user_input)
print(f"You entered: {number}")
except ValueError:
print("That is not a valid number")
Int on hello raises ValueError. The handler converts a crash into a message the user understands, and the program decides what to do next instead of stopping.
Handle different errors differently
# Multiple separate handlers - each error gets its own message
import tempfile, pathlib, os
# Case A: file not found
try:
open("no_such_file_404.txt", "r")
except FileNotFoundError:
print("File does not exist")
# Case B: bad int
try:
value = int("abc")
except ValueError:
print("File contains invalid data")
# Case C: permission error simulated via handler check
try:
raise PermissionError("denied")
except PermissionError:
print("No permission to read file")
Separate except blocks let you say File does not exist for one case and invalid data for another. Python checks them in order and runs only the first match, so each failure gets the right response.
Group handlers when the response is the same
# Tuple catch when handling is identical
def fetch_api_data(fail=True):
if fail:
raise ConnectionError("network down")
for exc in [ConnectionError("down"), TimeoutError("timed out")]:
try:
raise exc
except (ConnectionError, TimeoutError):
print(f"Network request failed: {type(exc).__name__}")
When ConnectionError and TimeoutError mean the same to you, catch them together as a tuple. I looped over both types and the single handler printed Network request failed for each, so you avoid duplicating the same body.
Read what the exception carries
# Capturing the exception object with as
def calculate_average(items):
if not all(isinstance(x, (int, float)) for x in items):
raise TypeError("unsupported operand type: expected numbers")
return sum(items) / len(items)
try:
result = calculate_average([1, 2, "three", 4])
except TypeError as error:
print(f"Error type: {type(error).__name__}")
print(f"Error message: {str(error)}")
print(f"Error args: {error.args}")
I captured the TypeError with as error and printed its type, message, and args. The object carries the same traceback you saw earlier, which means you can log it, inspect it, or attach more context before deciding.
Run success-only code in else
# else runs only on success - keep validation out of try
import json, tempfile, pathlib
# Create a temp valid config
tmp = pathlib.Path("/tmp/valid_config.json")
tmp.write_text('{"timeout": 30}')
try:
data = json.loads(tmp.read_text())
except FileNotFoundError:
print("Config missing, using defaults")
data = {"timeout": 10}
else:
print("Config loaded successfully")
print(f"timeout={data['timeout']}")
finally:
print("Configuration process complete")
tmp.unlink(missing_ok=True)
I wrote a valid JSON file and parsed it inside try. Because parsing succeeded, else printed Config loaded successfully.
When I later pointed the same code at a missing file, else did not run. That is why validation and follow-up work belong in else, not inside try.
# Why else matters - bug when validation lives inside try
import json
bad = '{"timeout": "bad"}'
# This shows the bug: TypeError inside try would be wrongly attributed if except were broader
# Keep it controlled
try:
data = json.loads(bad)
# validation wrongly inside try - if we caught ValueError broadly this would misfire
if not isinstance(data["timeout"], int):
raise TypeError("timeout must be int")
except TypeError as e:
print(f"Invalid type caught as TypeError: {e}")
print("Without else, file errors and validation errors share one try - hard to tell apart")
else:
print("This else would not run after TypeError")
print("--- correct shape ---")
try:
data = json.loads(bad)
except FileNotFoundError:
print("Config missing")
else:
# validation here, outside file-error handler
if not isinstance(data["timeout"], int):
print(f"Validation failed: timeout must be int, got {data['timeout']!r}")
else:
print("Valid")
The second sample shows both shapes. The wrong shape mixes parsing and validation inside one try, the correct shape keeps file handling in try and moves validation to else where it gets its own handling.
Guarantee cleanup with finally
# finally always runs - even with return
import tempfile
def read_with_finally():
tmp = tempfile.NamedTemporaryFile(mode="w+", delete=False)
tmp.write("hello")
tmp.close()
f = None
try:
f = open(tmp.name, "r")
return f.read()
except FileNotFoundError:
print("missing")
return ""
finally:
if f:
f.close()
print("File closed in finally")
import os
os.unlink(tmp.name)
print(read_with_finally())
I opened a temp file, returned its contents, and finally still printed File closed in finally. Return does not skip finally, and neither does an exception, which is exactly why handles and locks belong there.
# finally runs even when exception escapes
connection = None
class FakeConn:
def close(self):
print("connection closed")
try:
connection = FakeConn()
print("query running")
raise RuntimeError("query failed")
except RuntimeError as e:
print(f"Caught: {e}")
finally:
if connection:
connection.close()
print("Cleanup done")
The fake database sample raises during the query, except logs it, and finally still closes the connection. If cleanup must always happen, finally is where you put it.
How to raise exceptions and chain them with cause
Raising makes a bad value visible to the caller instead of letting it travel as a wrong number or silent None.
| Statement | What it does |
|---|---|
| raise ValueError(…) | Creates and throws a new error |
| raise from err | Chains the original as cause |
| raise from None | Hides the original context |
Validate with raise
# raise for validation
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > balance:
raise ValueError("Insufficient funds")
return balance - amount
print(withdraw(100, 30))
try:
withdraw(100, -5)
except ValueError as e:
print(f"Rejected: {e}")
try:
withdraw(50, 80)
except ValueError as e:
print(f"Rejected: {e}")
Withdraw checks the amount before touching balance. I ran it with 30, then with -5 and 80, and each bad case raised ValueError with a message that names the specific rule that failed.
Log then re-raise without losing the traceback
# Re-raise after logging preserves traceback
import logging, io
log_stream = io.StringIO()
handler = logging.StreamHandler(log_stream)
logger = logging.getLogger("demo")
logger.addHandler(handler)
logger.setLevel(logging.ERROR)
def call_api():
raise ConnectionError("api down")
try:
call_api()
except ConnectionError as error:
logger.error(f"API request failed: {error}")
print(f"Logged: {log_stream.getvalue().strip()}")
# Re-raise bare to preserve original traceback
try:
raise
except ConnectionError as e:
print(f"Re-raised: {e}")
print("Logged and re-raised without losing context")
I let the API call fail, logged API request failed with the original message, and then used bare raise inside except to re-throw the same object. The caller still sees the original type and traceback, while the log keeps a record at the boundary where the error was seen.
Chain a high-level error to its cause
# Exception chaining with raise from
def parse_config(path):
try:
value = int("not_a_number")
except ValueError as exc:
raise RuntimeError("Config parsing failed") from exc
try:
parse_config("dummy")
except RuntimeError as e:
print(f"Caught: {e}")
print(f"Cause type: {type(e.__cause__).__name__}")
print(f"Cause: {e.__cause__}")
# Show chained traceback hint
import traceback
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
print("Chained traceback includes cause:", "ValueError" in tb)
I caught ValueError from int and raised RuntimeError from the original. The printed Cause type was ValueError, and the chained traceback contains both layers. Callers who catch the high-level error can still inspect e.__cause__ for the low-level reason.

# Suppress context with raise from None
try:
try:
raise FileNotFoundError("original missing")
except FileNotFoundError as exc:
raise RuntimeError("converted error") from None
except RuntimeError as e:
print(f"Caught: {e}")
print(f"__cause__ is None: {e.__cause__ is None}")
print(f"__suppress_context__: {e.__suppress_context__}")
print(f"__context__ still holds: {type(e.__context__).__name__}")
When you deliberately replace the error and do not want the original shown, use raise from None. I suppressed FileNotFoundError and raised RuntimeError. The result has __cause__ as None while __context__ still holds the original for debugging.
Add context to an error you did not create
# Enriching exceptions with add_note (Python 3.11+)
try:
data = {"a": 1}
value = data["missing_key"]
except KeyError as e:
e.add_note("While looking up 'missing_key' in data dict")
e.add_note(f"Available keys: {list(data.keys())}")
print(f"Error: {e}")
print(f"Notes: {e.__notes__}")
# Show that notes appear in traceback
import traceback
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
print("Note in traceback:", "missing_key" in tb)
I caught a KeyError for missing_key and called add_note twice. The notes list appeared in the exception and in the formatted traceback, which is lighter than wrapping the error just to add a sentence.
Define your own exception types
# Custom exception basics
class ValidationError(Exception):
pass
def validate_age(age):
if not isinstance(age, int):
raise ValidationError("Age must be an integer")
if age < 0 or age > 130:
raise ValidationError(f"Age {age} is not actualistic")
return True
for val in [25, -3, "old", 200]:
try:
validate_age(val)
print(f"{val}: ok")
except ValidationError as e:
print(f"{val}: {e}")
ValidationError subclasses Exception directly, which the docs call out as the right parent. I ran it against 25, -3, old, and 200, and each bad value got a message that names why it is invalid.
# Custom exception with extra attributes
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient funds: balance {balance}, requested {amount}")
class Account:
def __init__(self, balance):
self.balance = balance
def withdraw(account, amount):
if amount > account.balance:
raise InsufficientFundsError(account.balance, amount)
account.balance -= amount
return account.balance
acct = Account(50)
try:
withdraw(acct, 80)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"Balance was {e.balance}, tried to withdraw {e.amount}")
print(f"Balance remains {acct.balance}")
InsufficientFundsError stores both balance and requested amount. I ran withdraw on a 50-balance account for 80, caught the custom type, and printed Balance was 50, tried to withdraw 80, so the handler can report without parsing a string. That is the practical payoff of a custom class.
Failures and gotchas that still break try except
The wrong handler hides the bug you needed to see. These three shapes are the ones I see mis-copied most.
- Broad handler before specific, so the specific never runs
- Bare except catching KeyboardInterrupt, so Ctrl-C breaks
- try finally when with would close the file for you
Put specific handlers before broad ones
# Hierarchy ordering matters - specific before broad
for value in ["hello", None, 42]:
try:
if value is None:
raise TypeError("None not allowed")
if isinstance(value, str):
raise ValueError("string not allowed")
print(f"{value} ok")
except ValueError as e:
print(f"ValueError handler: {e}")
except TypeError as e:
print(f"TypeError handler: {e}")
except Exception as e:
print(f"General handler: {e}")
# Wrong order demo - broad swallows specific
print("--- wrong order ---")
try:
raise ValueError("specific error")
except Exception as e:
print(f"Bare Exception caught: {e} (ValueError handler never reached)")
I ran three values through ValueError, TypeError, then Exception. Each hit the narrowest match. When I flipped the order and caught Exception first, that handler swallowed ValueError and the specific message never ran.
Never use bare except
# Bare except vs except Exception vs KeyboardInterrupt
# Simulate why bare except is dangerous
caught = []
try:
raise KeyboardInterrupt("user pressed ctrl-c")
except:
caught.append("bare except caught KeyboardInterrupt - bad!")
print(caught[-1])
# Correct: except Exception does not catch KeyboardInterrupt
try:
raise KeyboardInterrupt("user pressed ctrl-c")
except Exception:
print("This will not run")
except KeyboardInterrupt:
print("KeyboardInterrupt correctly propagates to outer handler")
# And SystemExit
try:
raise SystemExit(1)
except Exception:
print("Exception handler did not catch SystemExit - correct")
except SystemExit:
print("SystemExit correctly separate from Exception")
print("Lesson: never use bare except; use except Exception if you must broad-catch and reraise")
I raised KeyboardInterrupt under bare except and it got caught, which means Ctrl-C stops working inside that block. Except Exception does not catch KeyboardInterrupt or SystemExit, so those signals still propagate. If you must catch broadly, write except Exception as error, log, then raise.
Prefer with over try finally for files
# with as alternative to try/finally
import tempfile, pathlib
tmp = pathlib.Path("/tmp/demo_with.txt")
tmp.write_text("content\n")
# try/finally style
f = open(tmp, "r")
try:
print(f"try/finally: {f.read().strip()}")
finally:
f.close()
print("closed via finally")
# with style
with open(tmp, "r") as f:
print(f"with: {f.read().strip()}")
print("closed automatically by with")
tmp.unlink()
# Custom context manager
from contextlib import contextmanager
@contextmanager
def fake_db():
print("connecting")
try:
yield "connection"
finally:
print("disconnecting")
with fake_db() as conn:
print(f"using {conn}")
Both the try finally and the with block closed the file, but with does it without an explicit close line. I also wrapped a fake database in a contextmanager to show that with works for any resource that defines enter and exit. Use try finally only when with does not fit.
Know ExceptionGroup when several things fail at once
# ExceptionGroup (Python 3.11+) - handling multiple unrelated exceptions
try:
raise BaseExceptionGroup("multiple failures", [
ValueError("bad value 1"),
TypeError("bad type"),
ValueError("bad value 2"),
])
except* ValueError as eg:
print(f"Caught ValueError group: {len(eg.exceptions)} errors")
for e in eg.exceptions:
print(f" - {e}")
except* TypeError as eg:
print(f"Caught TypeError group: {len(eg.exceptions)}")
I built an ExceptionGroup holding a couple of ValueErrors and a TypeError. Each type was caught with except*. The ValueErrors arrived together, TypeError separately, which shows how Python 3.11 handles parallel failures without nesting.
Carry the shape through a file project
# Carried example - file parsing with layered handlers
import tempfile, pathlib, json
# Setup: create a temp file with mixed good/bad rows
tmp = pathlib.Path("/tmp/scores.csv")
tmp.write_text("alice,92\nbob,not_a_number\ncharlie,85\n")
def parse_scores(path):
results = {}
try:
f = open(path, "r")
except FileNotFoundError:
print("Score file not found, starting empty")
return {}
else:
# Success path - process file outside file-open handler
print("Score file opened")
finally:
pass
with open(path, "r") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
name, score_str = line.split(",", 1)
score = int(score_str)
except ValueError as e:
e.add_note(f"On line {lineno}: {line!r}")
print(f"Line {lineno} skipped: {e}")
if e.__notes__:
print(f" Note: {e.__notes__[0]}")
continue
else:
results[name] = score
print(f"Line {lineno}: {name} -> {score}")
finally:
pass
return results
scores = parse_scores(str(tmp))
print(f"Final scores: {scores}")
print(f"Average: {sum(scores.values())/len(scores):.1f}" if scores else "No valid scores")
tmp.unlink()
I wrote a small CSV with one bad row and opened it outside the row handler. Each row manages its own ValueError. Line 2 printed Line 2 skipped and the note On line 2, so the bad row did not stop the other two and the final average came from the good scores.

Test that a handler actually fires
# Testing handlers - like pytest.raises but without pytest
def raises(expected, func, *args, **kwargs):
try:
func(*args, **kwargs)
except expected:
print(f"Correctly raised {expected.__name__}")
return True
except Exception as e:
print(f"Wrong exception: {type(e).__name__}: {e}")
return False
else:
print(f"Failed: no exception raised, expected {expected.__name__}")
return False
def withdraw(balance, amount):
if amount > balance:
raise ValueError("Insufficient funds")
return balance - amount
raises(ValueError, withdraw, 50, 80)
raises(ValueError, withdraw, 50, 5) # should not raise - test will show failure
raises(ValueError, int, "hello")
I wrote a tiny raises helper that mirrors pytest.raises. It called withdraw with 80 and confirmed ValueError, then called it with 5 where no error should rise, and the helper reported the missing raise so the missing check is obvious before you ship.
What you now control and what to do next
You now have a failure that keeps its name. Try runs the risky line, except matches by hierarchy, else marks success, finally cleans up, and raise with from keeps the cause chain intact so the next reader knows where the error started.
| Habit | Why it helps |
|---|---|
| One risky call per try | Handler names the failing line |
| Narrowest except | Unexpected errors stay visible |
| Validation in else | Success logic stays separate |
Keep the habit small: one risky call per try, the narrowest except you can name, validation in else, and cleanup in finally or with. When a new rule needs its own name, subclass Exception and carry the data as attributes instead of stuffing it into a string.
Next, add logging at module boundaries where you reraise, test each handler with a raises check, and handle the two cleanup jobs in your project, usually files and network handles, with with so a failure cannot leave them open.
Python exception handling FAQ
What is the difference between try except else and finally?
try runs the risky code. except handles a matching error. else runs only when try succeeds, so it is for success-only follow-up. finally runs no matter what, so it is for cleanup. I verified both roles by writing a valid JSON file that triggered else and a failing query that still ran finally.
Should I use bare except or except Exception?
Use neither by default. Name the specific type you expect, like ValueError or FileNotFoundError. If you must catch broadly, use except Exception as error then log and reraise. Bare except also catches KeyboardInterrupt and SystemExit, which breaks Ctrl-C and sys.exit handling as shown in the KeyboardInterrupt demo.
When should I use raise from?
When you translate a low-level error into a high-level one. Write raise RuntimeError(“Config parsing failed”) from exc to chain the cause. The traceback shows both errors and e.__cause__ holds the original. Use raise … from None only when you intentionally hide the original and want a clean traceback.
How do I create a custom exception in Python?
Subclass Exception, not BaseException, and add any data you need as attributes. For example, InsufficientFundsError stores balance and amount so the handler can read them directly. Raise it with raise InsufficientFundsError(balance, amount) and catch it by its class name.
Does with replace try finally for files?
For files and other context managers, yes. with open(path) as f closes the file even when an exception is raised, with less code than try finally. Use try finally only for resources that do not offer a context manager, or use contextlib.contextmanager to make one.


