Both try-except and try-finally are exception handling constructs in Python, but they serve different purposes. Mixing them up produces code that works accidentally rather than by design. This guide covers when each pattern is appropriate, when to combine them, and the specific cases where the distinction matters most.
What Each Construct Does
try-except catches exceptions. When an exception occurs inside the try block, Python looks for an except clause that matches the exception type and executes it instead of propagating the exception.
try-finally guarantees cleanup. The finally block runs no matter what happens in the try block -- whether the code completes normally, raises an exception, executes a return statement, or even calls sys.exit(). Exceptions are not caught or suppressed; they continue propagating after finally runs.
# try-except: handles the exception
try:
result = int(user_input)
except ValueError:
result = 0 # Exception caught; execution continues here
# try-finally: guarantees cleanup regardless of outcome
connection = None
try:
connection = db.connect()
result = connection.query(sql)
finally:
if connection:
connection.close() # Always runs, even if query raised
When to Use try-except
Use try-except when you have a specific plan for what to do if an exception occurs. "A specific plan" means one of:
- Return a fallback value
- Log the error and continue with the next item
- Convert the exception to a different type (exception chaining)
- Re-raise with additional context
The critical distinction is catching specific exception types, not Exception or bare except:. Broad catches hide unexpected errors and make debugging harder.
# Good: specific exception type, specific handling
try:
user = db.get_user(user_id)
except UserNotFoundError:
return None
except DatabaseConnectionError as err:
raise ServiceUnavailableError("Database offline") from err
# Problematic: catches everything including programming errors
try:
process_record(record)
except Exception:
pass # Silently discards AttributeErrors, NameErrors, etc.
The Python language documentation covers the full built-in exception hierarchy and which errors should be caught versus propagated.
When to Use try-finally
Use try-finally when you need to release a resource or restore state, regardless of whether the operation succeeded. The resource does not have to be a file or database connection -- it could be a lock, a temporary directory, a global flag, or any state that must be cleaned up.
import tempfile
import os
def process_with_temp_file(data):
tmp_path = tempfile.mktemp()
try:
with open(tmp_path, "w") as f:
f.write(data)
return transform(tmp_path)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
The finally block here runs whether transform succeeds, raises, or even if the function is interrupted. The temp file is always cleaned up.
For resources that implement the context manager protocol -- files, database connections from most ORMs, locks -- a with statement is cleaner and equivalent:
with open(path) as f:
content = f.read()
# File is closed here even if read() raised
The explicit try-finally pattern remains appropriate when you need conditional cleanup, when the resource object is not a context manager, or when multiple resources need to be managed with different cleanup logic.
Combining try-except-else-finally
Python allows all four clauses together. The else clause adds a fourth behavior: code that runs only when no exception was raised.
conn = None
try:
conn = db.connect()
rows = conn.query(sql)
except DatabaseConnectionError as err:
log.exception("DB connection failed")
raise
except QueryError as err:
log.exception("Query failed: %s", sql)
raise
else:
# Only runs if try completed without raising
process_rows(rows)
log.info("Query returned %d rows", len(rows))
finally:
# Always runs
if conn:
conn.close()
The else clause is the most underused part of this pattern. Without it, success-path code goes inside try, which means a QueryError raised by process_rows would be caught by the except QueryError handler -- almost certainly not the intended behavior. Putting success-path logic in else limits the except clauses to errors that actually originate in the try block.
The Common Mistake: try-except as a Substitute for finally
A common mistake is using try-except to handle cleanup, under the assumption that catching exceptions covers all cases:
# Wrong: cleanup only happens if exception occurs
try:
connection = db.connect()
result = connection.query(sql)
except Exception:
connection.close() # This is cleanup, not exception handling
raise
# connection is never closed if query succeeds
This closes the connection if an exception occurs but not if the query succeeds. The correct structure uses finally for cleanup, with except only for cases where the caller has a specific response planned:
connection = db.connect()
try:
result = connection.query(sql)
except QueryError as err:
log.exception("Query failed: %s", sql)
return None
finally:
connection.close() # Always runs
When to Separate Concerns Into Separate try Blocks
When you need different handling for different operations, separate them into different try blocks rather than catching multiple exception types in one broad handler.
# Single try block: hard to tell which operation failed
try:
user = db.get_user(user_id)
profile = api.fetch_profile(user.email)
send_notification(user, profile)
except Exception as err:
log.error("Something failed: %s", err)
# Separated: each operation has its own handler
try:
user = db.get_user(user_id)
except UserNotFoundError:
return {"error": "not_found"}
try:
profile = api.fetch_profile(user.email)
except APIError as err:
log.warning("Profile fetch failed for %s: %s", user.email, err)
profile = None
send_notification(user, profile)
The separated version makes the intent explicit at each point and avoids the ambiguity of which operation the exception came from.
Testing Both Paths
The pytest documentation at docs.pytest.org covers pytest.raises for asserting that exceptions are raised and mocker.patch for simulating failures:
def test_cleanup_runs_on_failure(mocker):
mock_conn = mocker.MagicMock()
mocker.patch("db.connect", return_value=mock_conn)
mocker.patch.object(mock_conn, "query", side_effect=QueryError)
with pytest.raises(QueryError):
run_query(sql)
mock_conn.close.assert_called_once() # Verify finally ran
Testing that cleanup runs on failure confirms the finally block is doing its job. Without this test, a refactor that accidentally moves the close() call inside else would not be caught until production.
Context Managers as a Formalized try-finally
The with statement is Python's built-in way to encapsulate try-finally cleanup into a reusable object. When you open a file with with open(path) as f:, the file object's __enter__ method runs at entry and __exit__ method runs at exit, regardless of whether an exception occurred. It is precisely equivalent to wrapping the block in try-finally.
You can define your own context managers using contextlib.contextmanager:
from contextlib import contextmanager
@contextmanager
def managed_connection():
conn = db.connect()
try:
yield conn
finally:
conn.close()
with managed_connection() as conn:
result = conn.query(sql)
The yield splits the function into the setup (before yield) and teardown (after yield) phases. The finally in the context manager function guarantees the teardown runs. This is a cleaner way to express reusable resource management than repeating try-finally blocks throughout the codebase.
The Python documentation at docs.python.org covers the context manager protocol (__enter__ and __exit__) and the contextlib module in depth. The contextlib.suppress function is also part of contextlib and provides a concise alternative to try-except: pass for cases where an exception is genuinely expected and should be silenced cleanly. Python Enhancement Proposal 343 on peps.python.org introduced the with statement and explains the design intent behind the context manager protocol -- the __enter__ / __exit__ approach was chosen specifically to guarantee cleanup even in the presence of exceptions, return statements, and generator-based control flow.
Reference
The full collection of Python exception handling patterns -- including exception chaining, contextlib.suppress, logging with log.exception(), and custom exception hierarchies -- is in the Python Error Handling article on the 137Foundry blog. The try-except-else-finally pattern is covered there with additional context on how it fits into service boundary design.
Top comments (0)