Your program is humming along, then someone types letters where you expected a number and it dies on the spot. Moments like that are exactly what python exception handling is for. Rather than letting one bad value take down everything, you catch the error, decide how to respond, and keep the program alive. This post covers try, except, else, and finally, plus how to read a traceback, all with tested examples on Python 3.14.6.
“Errors should never pass silently. Unless explicitly silenced.”
Tim Peters, PEP 20
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Beginner | Reading Time: 20 minutes
Python exception handling is how Python deals with errors while your program is running. Something goes wrong (a file is missing, a network call times out, a user types letters where you expected a number) and Python raises an exception. If you do nothing, that exception crashes the whole program. With try, except, else, and finally, you catch the error, decide how to respond, and keep going.
Think of it like driving a car. Hitting a red light is not a crash. You expected it, you stop, you wait, you carry on. Exception handling is your code seeing the red light coming and stopping calmly instead of plowing through the intersection. The goal is never to hide the error. The goal is to decide what to do about it.
So your program crashes and Python prints a wall of red text. Your first instinct is to panic. Your second is to paste the last line into a search box. Here is the good news: that red text, the traceback, is telling you exactly what broke, exactly where, and exactly why. Once you can read it, a bug stops being a catastrophe and becomes a short conversation. This post walks through the whole thing, from catching one specific error to the cleanup guarantee of finally, with every example run on Python 3.14.6.
Table of Contents
Anatomy of a Python Traceback
Before you learn to catch exceptions, learn to read them. A traceback is just Python showing its work, a receipt of every function it was inside when things fell apart. Here is a real one, line by line, from a tiny script that averages exam scores for a student named Rahul.
📄 traceback_example.py: a function that will fail
def calculate_average(scores):
total = sum(scores)
return total / len(scores)
def process_student(name, scores):
avg = calculate_average(scores)
return f"{name}: {avg:.1f}"
# This will crash because the list is empty
result = process_student("Rahul", [])
▶ Traceback
Traceback (most recent call last):
File "traceback_example.py", line 10, in <module>
result = process_student("Rahul", [])
File "traceback_example.py", line 6, in process_student
avg = calculate_average(scores)
File "traceback_example.py", line 3, in calculate_average
return total / len(scores)
~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero
How to read it: start at the bottom and work up. The very last line is the punchline: ZeroDivisionError: division by zero. That tells you what broke. The frames above it are the trail of breadcrumbs showing how you got there. Line 10 called process_student, which called calculate_average, which tried to divide by len([]), and the length of an empty list is 0. The frame closest to the actual error sits at the bottom, which is why you read upward.
One more gift, around since Python 3.11 and standard in 3.14: see that little ~~~~~~^~~~~~~~~~~~~ line under the failing code? Those carets point at the exact expression that blew up, here total / len(scores). You no longer have to guess which part of a busy line caused the problem. Python underlines it for you.
The Basic try/except
The shape is simple. You put the risky line inside try, and you put your backup plan inside except. If the risky line works, Python skips the except entirely. If it throws the error you named, Python jumps straight into except and runs your backup plan instead of crashing. It is the same as a recipe that says “preheat the oven, but if the oven is broken, use the stovetop.” You name the thing that might fail and you name what to do instead.
📄 basic_try.py: catching an exception
def safe_divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero!")
result = None
return result
print(safe_divide(10, 3)) # Normal case
print(safe_divide(10, 0)) # Error case, caught!
print("Program continues...") # This still runs
▶ Output
3.3333333333333335 Cannot divide by zero! None Program continues...
What happened here: the first call, safe_divide(10, 3), runs fine and prints the result. The second call divides by zero, which would normally kill the program on the spot. Instead Python catches the ZeroDivisionError, prints a friendly message, sets result to None, and carries on. The last line still prints. That is the whole point: the program does not crash, and you got to decide what happens when it went wrong.
Catching Specific Exceptions
Here is a rule worth tattooing on your brain: catch the specific error you expect, not “any error at all”. A bare except: looks tempting because it catches everything, but everything includes KeyboardInterrupt (the Ctrl+C you press to stop a runaway program) and SystemExit. Catch those by accident and your program becomes weirdly hard to kill. Naming the exact exception is like telling a bouncer “only stop people in red shirts” instead of “stop everyone”, which would include the staff trying to leave. In the example below we deliberately trip four different errors: first a bad-input ValueError, then three lookup errors on the records of a student named Aviraj.
📄 specific_exceptions.py: catch what you expect
def get_user_age():
user_input = "twenty-five" # Simulating bad input
try:
age = int(user_input)
except ValueError:
print(f"'{user_input}' is not a valid number")
age = None
return age
print(f"Age: {get_user_age()}")
print()
# Multiple exception types in examples
data = {"name": "Aviraj", "scores": [88, 92]}
try:
print(data["email"]) # KeyError: this key does not exist
except KeyError as e:
print(f"Missing key: {e}")
try:
print(data["scores"][5]) # IndexError: index out of range
except IndexError:
print("Index out of range!")
try:
result = "hello" + 42 # TypeError: cannot add str and int
except TypeError as e:
print(f"Type error: {e}")
▶ Output
'twenty-five' is not a valid number Age: None Missing key: 'email' Index out of range! Type error: can only concatenate str (not "int") to str
What happened here: each try block targets one kind of mistake. A missing dictionary key raises KeyError, an index past the end of a list raises IndexError, and adding a string to a number raises TypeError. Notice the messages are different and useful. Python tells you the missing key was 'email' and even explains that you “can only concatenate str (not “int”) to str”. When you catch the right exception, you get to turn those raw messages into something your user can actually understand.
Multiple except Clauses
One try can have several except clauses stacked under it, each handling a different problem. It works like the reception desk at a hospital: a fever goes to one department, a fracture to another, each complaint routed to its own specialist. Reading a config file is the classic example. The file might not exist, it might exist but contain broken JSON (JavaScript Object Notation), or it might be fine but missing the key you asked for. Three different failures, three different messages.
📄 multiple_except.py: handle different errors differently
def read_config(filename, key):
try:
with open(filename, "r") as f:
import json
data = json.load(f)
return data[key]
except FileNotFoundError:
print(f"Config file '{filename}' not found")
except json.JSONDecodeError:
print(f"Config file '{filename}' contains invalid JSON")
except KeyError:
print(f"Key '{key}' not found in config")
return None
# Test each error path
print(read_config("missing.json", "host")) # FileNotFoundError
print()
# Create an invalid JSON file
with open("bad.json", "w") as f:
f.write("{invalid json}")
print(read_config("bad.json", "host")) # JSONDecodeError
print()
# Create a valid JSON file missing the key
with open("good.json", "w") as f:
f.write('{"port": 8080}')
print(read_config("good.json", "host")) # KeyError
print(read_config("good.json", "port")) # Success!
▶ Output
Config file 'missing.json' not found None Config file 'bad.json' contains invalid JSON None Key 'host' not found in config None 8080
What happened here: each call hit a different failure and landed in a different except clause, and the last call found its key and returned 8080. Order matters here. Python checks the clauses top to bottom and runs the first one that matches, then stops looking. So always put the specific exceptions first and the general ones last. If you put a broad except Exception at the top, it would swallow everything and your specific handlers below would never get a turn.
The else Clause: Run Only If No Exception
The else block is the one most people skip, and that is a shame because it is genuinely useful. It runs only when the try block finished with no exception at all. Think of it as the “all clear” step. The try does the risky part, and else holds the work that only makes sense once the risky part succeeded. It is like a delivery app asking you to rate the food only after the order actually arrived. Below, we look up scores for three students: Rahul and Pravin are in the records, while a third student, Anvi, is not.
📄 else_clause.py: else runs when try succeeds
scores = {"Rahul": 92, "Niranjan": 88, "Pravin": 45}
for name in ["Rahul", "Anvi", "Pravin"]:
try:
score = scores[name]
except KeyError:
print(f" {name}: NOT FOUND in records")
else:
# Only runs if try succeeded (no exception)
status = "PASS" if score >= 50 else "FAIL"
print(f" {name}: {score}, {status}")
▶ Output
Rahul: 92, PASS Anvi: NOT FOUND in records Pravin: 45, FAIL
What happened here: Rahul and Pravin are in the dictionary, so the lookup succeeds and the else block runs to work out PASS or FAIL. Anvi is not in the records, so the lookup raises KeyError, the except runs, and the else is skipped. Why not just put the status line at the end of the try? Because else only guards against errors from the lookup. If the status calculation itself blew up, you would want that error to surface loudly, not get quietly mistaken for a missing key by your except KeyError. Keeping the success path in else means each block has exactly one job.
The finally Clause: Always Runs
If else is the optimist, finally is the realist. Whatever happens, success or failure, caught error or even an early return, the finally block runs. It is the “no matter what” cleanup step. A good way to picture it: finally is turning off the stove before you leave the kitchen. Dinner came out great or you burned it to a crisp, does not matter, you still turn off the stove on your way out.
📄 finally_clause.py: cleanup code that always executes
def process_data(filename):
print(f" Opening {filename}...")
try:
with open(filename) as f:
data = f.read()
result = int(data.strip()) # Could raise ValueError
return result * 2
except FileNotFoundError:
print(" File not found!")
return None
except ValueError:
print(" File doesn't contain a valid number!")
return None
finally:
# This ALWAYS runs: success, failure, even with return!
print(f" Cleanup complete for {filename}")
# Success case
with open("number.txt", "w") as f:
f.write("42")
print(f"Result: {process_data('number.txt')}")
print()
# Failure case
print(f"Result: {process_data('nonexistent.txt')}")
▶ Output
Opening number.txt... Cleanup complete for number.txt Result: 84 Opening nonexistent.txt... File not found! Cleanup complete for nonexistent.txt Result: None
What happened here: look closely at the success case. The try block hits return result * 2, which would normally end the function right there. But the “Cleanup complete” line still prints before the result comes back. That is finally at work. It runs even when a return is on its way out the door. So finally is where you put the work that has to happen regardless: closing connections, releasing locks, deleting temp files. You will reach for it constantly once you start working with real resources.
The Complete Pattern
Now let us put all four Python exception handling clauses in one place so you can see them work together. Think of a restaurant order: the kitchen tries to cook it, a ruined dish gets an apology (except), a good dish gets its garnish (else), and either way the counter gets wiped down before the next order (finally). This load_config function reads a JSON file: try attempts the read, two except clauses handle the two ways it can fail, else runs the success-only work, and finally prints a closing note every single time.
The diagram traces how Python moves through a try/except/else/finally block, and there are really only two paths. If nothing goes wrong, Python runs try, then else, then finally. If something does go wrong, Python runs try up to the point it broke, then the matching except, then finally. Notice that finally sits at the bottom of both paths. It always runs, which is exactly why it is the right home for cleanup work like closing files or database connections. And if no except clause matches at all, the exception still passes through finally on its way out, then propagates up for the caller to handle.
📄 complete_pattern.py: all four clauses together
import json
def load_config(path):
"""Load and validate a JSON config file."""
print(f"Loading config from {path}...")
try:
with open(path, "r", encoding="utf-8") as f:
config = json.load(f)
except FileNotFoundError:
print(" ERROR: Config file not found")
return {}
except json.JSONDecodeError as e:
print(f" ERROR: Invalid JSON, {e}")
return {}
else:
# Only runs if try succeeded
print(f" Loaded {len(config)} settings")
return config
finally:
# Always runs
print(" Config loading attempt complete")
# Test with valid config
with open("app_config.json", "w") as f:
json.dump({"debug": True, "port": 8080, "name": "TechnoScripts"}, f)
config = load_config("app_config.json")
print(f"Config: {config}\n")
# Test with missing file
config = load_config("nope.json")
print(f"Config: {config}")
▶ Output
Loading config from app_config.json...
Loaded 3 settings
Config loading attempt complete
Config: {'debug': True, 'port': 8080, 'name': 'TechnoScripts'}
Loading config from nope.json...
ERROR: Config file not found
Config loading attempt complete
Config: {}
What happened here: trace the two runs in the output. The valid file loads, so you see the else message (“Loaded 3 settings”) and then the finally message. The missing file triggers the except message instead, then the same finally message. The order never changes: try runs first, then either else (success) or the matching except (failure), and finally always brings up the rear. Memorize that order and the rest is detail.
Accessing Exception Details
So far we have just printed a fixed message. But the exception itself is an object, and it carries useful details: its type, its message, and the full traceback. Think of it as an accident report rather than just a dented bumper: it does not merely say something went wrong, it records what, where, and how. You grab that object with except SomeError as e, and then e is yours to inspect.
📄 exception_details.py: getting info from the exception object
import traceback
try:
numbers = [1, 2, 3]
print(numbers[10])
except IndexError as e:
print(f"Exception type: {type(e).__name__}")
print(f"Exception message: {e}")
print(f"Exception args: {e.args}")
print()
print("Full traceback:")
traceback.print_exc()
▶ Output
Exception type: IndexError
Exception message: list index out of range
Exception args: ('list index out of range',)
Full traceback:
Traceback (most recent call last):
File "exception_details.py", line 5, in <module>
print(numbers[10])
~~~~~~~^^^^
IndexError: list index out of range
What happened here: the as e syntax handed us the exception object. From there, type(e).__name__ gives the class name (IndexError), str(e) gives the human message (“list index out of range”), and e.args gives the raw tuple Python built the message from. The call to traceback.print_exc() prints the full traceback, the same red text you would see on a crash, but without actually crashing. And notice the caret markers (~~~~~~~^^^^) pointing right at numbers[10]. Those have shipped since Python 3.11 and are part of the standard traceback in 3.14, so on busy lines you always know exactly which piece misbehaved.
Nested Exception Handling
Sometimes you want a small, local handler for one likely problem, wrapped inside a bigger safety net for everything else. That is nesting: a try/except living inside another try/except. The inner one deals with the expected, recoverable case. The outer one is the catch-all for surprises you did not see coming. It is like keeping a first-aid kit in the kitchen for small cuts while the hospital across town handles anything serious.
📄 nested_handling.py: try/except inside try/except
def fetch_user_data(user_id):
"""Simulates reading user data from a file, then parsing it."""
try:
# Outer try: file operations
filename = f"user_{user_id}.json"
try:
with open(filename, "r") as f:
import json
data = json.load(f)
except FileNotFoundError:
print(f" Creating default profile for user {user_id}")
data = {"id": user_id, "name": "New User", "score": 0}
# Process the data (could raise its own errors)
score = data["score"]
grade = "A" if score >= 90 else "B" if score >= 80 else "C"
return f"User {data['name']}: Grade {grade}"
except Exception as e:
# Outer catch: anything unexpected
return f"Unexpected error: {e}"
print(fetch_user_data(999))
▶ Output
Creating default profile for user 999 User New User: Grade C
What happened here: there is no file called user_999.json, so the inner try raises FileNotFoundError. The inner except handles it gracefully by building a default profile, and the function keeps going as if nothing happened. The outer except Exception never had to fire this time, but it is there as a backstop. If the data had been malformed in some way we did not anticipate, the outer net would catch it and return a clean error message instead of letting the program fall over. A score of 0 lands in the “C” grade, which is why you see Grade C.
Common Mistakes
Mistake 1: Bare except:, the Silent Bug Factory
📄 mistake_bare_except.py
# TERRIBLE: catches EVERYTHING including KeyboardInterrupt
# try:
# do_something()
# except: # Never do this!
# pass
# BAD: too broad, hides real bugs
# try:
# do_something()
# except Exception:
# pass
# GOOD: catch specific exceptions
try:
value = int("hello")
except ValueError as e:
print(f"Handled: {e}")
▶ Output
Handled: invalid literal for int() with base 10: 'hello'
What happened here: only the GOOD version actually runs. The first two are commented out on purpose, because they are traps. A bare except: grabs literally everything, even the Ctrl+C you press to quit, so your program turns into a roach motel that nothing can leave. The GOOD version names ValueError, catches exactly that, and prints a clear message. Catch what you expect, let the rest fly.
Mistake 2: except Exception: pass, swallowing errors silently
This one is sneaky because it does not crash. You catch the exception, do nothing, and move on. Then later you spend two hours wondering why the numbers are wrong, with no error message to guide you. A silent failure is worse than a loud one, because at least the loud one tells you where to look. If you truly have nothing to do about an error yet, at minimum log it so there is a trail to follow.
Mistake 3: Putting too much code in try
Keep the try block small. Wrap only the line or two that might raise the error you are trying to handle, not the whole function. The more code you cram inside try, the higher the chance you accidentally catch a completely unrelated bug and quietly mistake it for the error you meant to handle. A tight try is an honest try.
Best Practices
- DO catch specific exception types (
ValueError,KeyError), not bareexcept: - DO use
elsefor code that should only run whentrysucceeded - DO use
finallyfor cleanup (closing resources, releasing locks) - DO read tracebacks bottom-to-top (error type first, then call chain)
- DON’T use bare
except:, it catchesKeyboardInterruptandSystemExit - DON’T silently swallow exceptions with
pass, at least log them - DON’T put 50 lines of code inside a
tryblock, keep it focused
Conclusion
Python exception handling is how you write programs that bend instead of break. try wraps the risky code. except catches the specific errors you expect. else runs when nothing went wrong. finally always runs, so cleanup lives there. And the traceback? Read it bottom to top: the last line tells you what happened, the frames above tell you where. Get comfortable with these four words and a wall of red text, and your programs stop falling over in production. They handle the bad day and keep serving everyone else.
Next up: Raising Exceptions and Custom Exception Classes. Sometimes you are the one who needs to sound the alarm. These same handling patterns matter even more once you reach context managers for resource cleanup, pytest for testing your error paths, and REST Application Programming Interface (API) calls, where a network failing on you is not an “if” but a “when”.
New here, or want to jump to a different topic? Every post in order, from first print statement to machine learning, lives on the Python + AI/ML tutorial series home.
Practice Exercises
- Exercise 1: Catch
ValueErroron int conversion. - Exercise 2: Create
InsufficientFundsErrorfor a withdrawal function. - Exercise 3: Build a retry decorator with exponential backoff.
Frequently Asked Questions
What is the difference between try/except and if/else?
if/else handles expected conditions (check before acting). try/except handles unexpected failures (act and recover). Use if for validating data upfront, and Python exception handling with try/except for operations that might fail (file I/O, network, parsing).
What does the else clause do in try/except?
The else block runs only if the try block completed without raising any exception. It keeps success-path code separate from the error-prone code, preventing accidental catching of unrelated exceptions.
Does finally always run in Python?
Yes. The finally block runs whether the try succeeded, an exception was caught, or even if a return statement was executed inside try or except. The only exception is os._exit() or a power failure.
Why should I not use bare except?
Bare except: catches everything including KeyboardInterrupt (Ctrl+C) and SystemExit. This makes your program impossible to stop gracefully. Always catch specific exceptions like ValueError or at most Exception.
How do I read a Python traceback?
Read from bottom to top. The last line shows the exception type and message. The lines above show the call chain, and each frame shows the file, line number, function name, and the code that was executing. The bottom frame is where the error actually occurred.
Can I catch multiple exception types in one except?
Yes. Use a tuple: except (ValueError, TypeError) as e:. This catches both types with the same handler. Useful when different errors require the same response.
Interview Questions on Python Exception Handling
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: Your nightly script processes 50,000 records and one malformed record crashes the entire run at 3 AM. How do you make it resilient without hiding real bugs?
Wrap only the per-record work in a try/except and catch the specific exceptions bad data can raise, such as ValueError or KeyError. Log the failing record and the error message, count the failures, and let the loop continue to the next record. Do not catch Exception broadly and move on silently, because a genuine bug (wrong column name, broken import) should still crash loudly so you notice it. A good pattern is: skip and log known bad-data errors, alert or fail the job if the failure count crosses a threshold.
Q: A report job finishes “successfully” every day, but the totals are wrong and the logs show no errors. You open the code and find the whole function wrapped in except Exception: pass. What do you do?
That handler is swallowing every exception silently, so records are being dropped without a trace. First, replace pass with logging.exception("...") and re-run to see what has actually been failing all along. Then shrink the try block to just the lines that can legitimately fail and catch only the specific exception types you expect. The wrong totals almost certainly come from iterations that died halfway and were never counted.
Q: Walk me through the exact execution order of try/except/else/finally, both when an exception occurs and when it does not.
On success: the try body runs to the end, then else, then finally. On failure: the try body runs up to the line that raised, then the first matching except clause (checked top to bottom), then finally; the else block is skipped. If no except clause matches, finally still runs and then the exception propagates up to the caller. finally is always last, which is why cleanup belongs there.
Q: What happens if the finally block itself contains a return statement?
A return in finally overrides any return from try or except, and it even cancels an exception that was in flight, so the error vanishes without a trace. This is such a common source of bugs that Python 3.14.6 emits a SyntaxWarning for return, break, or continue statements that exit a finally block (PEP 765). Keep finally strictly for cleanup: close things, release things, and never control flow out of it.
Q: How do you log an exception but still let it crash the caller?
Catch it, log it, then use a bare raise statement: except ValueError as e: logger.exception("parse failed"); raise. A bare raise re-raises the exact same exception with its original traceback intact, so the caller sees the real failure point, not your handler. This is the standard “observe but do not handle” pattern for code that needs visibility without taking responsibility for recovery.
Q: A production traceback says “During handling of the above exception, another exception occurred” with two stacked tracebacks. How do you read it?
It means an exception was raised, and while the except block was handling it, a second exception was raised inside the handler itself. The top traceback is the original error, the bottom one is the error in your error handling, and the bottom one is what actually escaped. Fix the handler bug first (it is often a typo or a bad assumption in the recovery code), then decide whether the original error still needs better handling.
Want more? the official Python documentation documents everything this post could not fit.
Related Posts
Previous: Python: Working with CSV and JSON Files
Next: Python: Raising Exceptions & Custom Exception Classes
Series Home: Python + AI/ML Tutorial Series

No comment