Python finally and else in Exception Handling: The Two Clauses Most Developers Skip
The try/except pattern is familiar. The else and finally clauses are less so — most developers either do not know about them or consider them optional extras. They are not extras; they solve specific problems that try/except alone cannot handle cleanly.
else: Code That Belongs to the Happy Path
The else clause in a try/except block runs only when no exception was raised in the try block. If an exception was raised and caught (by any except clause), else is skipped.
def read_config(path): try: f = open(path) except FileNotFoundError: print(f"Config not found: {path}") return {} else: # This runs only if open() succeeded import json config = json.load(f) f.close() return configWhy not just put json.load(f) inside the try block? Because if json.load(f) raises a JSONDecodeError, it would fall through to the FileNotFoundError handler. That handler would print a misleading message about the file not being found, when the actual problem is malformed JSON.
else separates concerns: the try block handles the one thing that might fail in the expected way, and else handles the code that should only run on success.
def convert_to_int(value): try: number = int(value) except ValueError: print(f"Cannot convert {value!r} to int") return None else: # Only reached if int() succeeded if number < 0: print(f"Warning: negative value {number}") return number
print(convert_to_int("42")) # returns 42print(convert_to_int("-10")) # prints warning, returns -10print(convert_to_int("hello")) # prints error, returns Nonefinally: Cleanup That Always Runs
finally runs after the try block and any matching except or else block — regardless of whether an exception occurred, was caught, or was not caught.
This is the key property: finally runs even when an exception is not handled. If an exception propagates out of the function, finally still runs before the stack unwinds further.
def write_report(path, data): file = None try: file = open(path, "w") file.write(generate_report(data)) print("Report written successfully") except IOError as e: print(f"Failed to write report: {e}") finally: if file: file.close() # always closes, even if generate_report() raised print("File handle closed")The modern equivalent uses a with statement, which handles cleanup automatically:
def write_report(path, data): try: with open(path, "w") as file: file.write(generate_report(data)) except IOError as e: print(f"Failed to write report: {e}")But not every resource supports the context manager protocol. finally is the right tool when with is not available:
import threading
lock = threading.Lock()
def update_shared_state(value): lock.acquire() try: # Might raise — lock must be released even if it does shared_data["value"] = process(value) finally: lock.release() # always releases the lockfinally Runs Even After return
This surprises many developers. If both the try block and the finally block contain return statements, the finally return wins.
def demonstrate(): try: print("In try") return "from try" finally: print("In finally") # this runs before the return # If you added: return "from finally" # that would override the try's return
result = demonstrate()# In try# In finallyprint(result) # from tryIf finally contains its own return, it replaces the try block’s return value:
def risky(): try: return "success" finally: return "overridden" # this wins
print(risky()) # overriddenThis is almost always unintentional. Avoid return inside finally unless you genuinely want to override the exit value.
The Complete Four-Clause Pattern
def transfer_funds(from_account, to_account, amount): connection = get_db_connection() try: connection.begin_transaction() from_account.debit(amount) to_account.credit(amount) except InsufficientFundsError as e: connection.rollback() raise # re-raise after rollback except Exception as e: connection.rollback() raise RuntimeError("Transfer failed due to an unexpected error") from e else: connection.commit() # only commits if no exception occurred finally: connection.close() # always closes the connection
# Flow for each outcome:# Success: try → else → finally# InsufficientFunds: try → except → finally (then re-raises)# Unexpected error: try → except → finally (then re-raises RuntimeError)The separation here is meaningful:
excepthandles error recovery (rollback).elsehandles success completion (commit).finallyhandles cleanup regardless (close connection).
When else Is More Readable Than the Alternative
Compare these two equivalent approaches:
# Without else: harder to see which exceptions are expectedtry: connection = connect(host) data = connection.fetch() process(data)except ConnectionError: log_error("Failed to connect")
# With else: clear separationtry: connection = connect(host)except ConnectionError: log_error("Failed to connect")else: data = connection.fetch() process(data)In the first version, if connection.fetch() or process(data) raises a ConnectionError, the except handler fires — which is probably wrong. The else version makes it clear that only connect(host) is expected to raise ConnectionError. Any exception from fetch() or process() will propagate uncaught, which is the correct behaviour.
Practical Guidelines
Use else when you have code that should run only on success and you want to avoid accidentally catching exceptions from that success code in the same except clause.
Use finally for cleanup: closing files, releasing locks, disconnecting from external services, flushing buffers. If cleanup must happen unconditionally, finally is correct. If cleanup only makes sense after success, consider the else block or a with statement.
Prefer with over manual finally for resources. File handles, network connections, database sessions — these all support the context manager protocol in Python, and with is cleaner than writing try/finally by hand.
Do not use return in finally unless you specifically intend to override the function’s return value and suppress any exception.