Technology  /  Python

🐍 Python 78 guides · updated 2026

From first variable to OOP, generators, and real projects — the language that runs everything from data pipelines to AI agents, taught the practical way.

Python Exception Handling: try, except, else, and finally in Real Code

Every program encounters unexpected situations: a file that doesn’t exist, user input that isn’t a number, a network request that times out. Python’s exception handling mechanism gives you a structured way to respond to these situations rather than letting the program crash.

What Are Exceptions?

An exception is an object that Python creates when something goes wrong during execution. If you do not handle it, Python prints a traceback and terminates your program.

# Unhandled exception
result = 10 / 0
# ZeroDivisionError: division by zero

Wrapping risky code in a try block lets you catch and handle the exception instead.

The try/except Block

try:
number = int(input("Enter a number: "))
result = 100 / number
print(f"Result: {result}")
except ZeroDivisionError:
print("Cannot divide by zero — please enter a non-zero number")
except ValueError:
print("That's not a valid integer")

Python tries the try block. If a ZeroDivisionError occurs, it runs the first except block. If a ValueError occurs, it runs the second. If neither occurs, both except blocks are skipped. Execution then continues normally after the whole try/except structure.

Always catch specific exceptions. A bare except: with no exception type catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit, which makes your program harder to stop.

# Too broad — catches things you should not suppress
try:
do_something()
except:
pass
# Better — catch what you actually expect
try:
do_something()
except ValueError as e:
print(f"Value error: {e}")

The as Clause: Accessing the Exception

Use as to bind the exception object to a name. The exception carries a message and sometimes additional attributes.

import json
def load_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
print(f"Config file not found: {e.filename}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON at line {e.lineno}: {e.msg}")
return {}
config = load_config("settings.json")

The e.filename, e.lineno, and e.msg attributes come from the specific exception types, giving you enough context to show a useful error message.

The else Block: Code That Runs Only on Success

The else block runs if the try block completed without raising any exception. It is the right place for code that should only run when the try block succeeded.

def read_number_from_file(path):
try:
with open(path) as f:
content = f.read().strip()
except FileNotFoundError:
print(f"File not found: {path}")
return None
else:
# Only runs if open() succeeded
try:
return int(content)
except ValueError:
print(f"File content is not an integer: {content!r}")
return None

Using else keeps the success path separate from error handling, making both easier to read.

The finally Block: Cleanup That Always Runs

finally runs regardless of whether an exception occurred, was caught, or was re-raised. Use it for cleanup that must happen in every case.

def process_file(path):
f = None
try:
f = open(path)
data = f.read()
return data.upper()
except FileNotFoundError:
print(f"Cannot find {path}")
return None
finally:
if f:
f.close() # always closes the file if it was opened
print("File closed")

In modern Python, you rarely write finally for file handling because with handles cleanup automatically. But finally is valuable for resources that don’t support the context manager protocol — database connections, network sockets, locks.

The Complete Pattern

All four clauses can appear together, though you will not need all of them in every situation:

def safe_divide(a, b):
try:
result = a / b # might raise ZeroDivisionError
except ZeroDivisionError:
print("Cannot divide by zero")
result = None
else:
print(f"{a} / {b} = {result}") # runs only on success
finally:
print("Division attempt complete") # always runs
return result
safe_divide(10, 2)
# 10 / 2 = 5.0
# Division attempt complete
safe_divide(10, 0)
# Cannot divide by zero
# Division attempt complete

Nested try Blocks

Sometimes you need different handling for different parts of a function:

def fetch_and_parse(url):
import urllib.request
import json
try:
response = urllib.request.urlopen(url, timeout=5)
except urllib.error.URLError as e:
raise ConnectionError(f"Failed to reach {url}: {e.reason}") from e
try:
data = json.loads(response.read().decode())
except json.JSONDecodeError as e:
raise ValueError(f"Server returned invalid JSON: {e}") from e
return data

Separating network errors from parsing errors makes the failure modes explicit to callers.

Common Mistakes

Silently swallowing exceptions. An empty except block hides bugs and makes debugging nearly impossible.

# Anti-pattern — you'll never know what went wrong
try:
complex_operation()
except Exception:
pass # silent failure

Catching Exception too broadly. Unless you are at the very top of your call stack (like a global error handler), catching all Exceptions masks specific problems you should be handling individually.

Using exceptions for flow control. Exceptions are for exceptional situations. Using try/except to replace if checks is a code smell in most cases:

# Avoid: using exceptions as flow control
try:
value = my_dict["key"]
except KeyError:
value = "default"
# Better: use dict.get()
value = my_dict.get("key", "default")

Forgetting that finally runs even if you return from the try block. The finally block executes before the return actually happens, which can cause surprising behaviour if you modify the return value inside finally.