Learning Goals
3 minBy the end of this lesson you can:
- Put a happy-path block in
else— code that runs only iftrysucceeded. - Put cleanup code in
finally— runs whether or not anything went wrong. - Predict the exact print order of a
try / except / else / finallyblock. - Explain why
finallystill runs even if youreturnfromtry.
Warm-Up · Where Should the Print Go?
5 minSuppose we open a file, do something with it, and want to print "File handled." at the end. Where should that print go?
try: f = open("notes.txt") text = f.read() f.close() print("File handled.") # here? except FileNotFoundError: print("Not found.") print("File handled.") # or also here?
If we put it inside try, it's skipped on error. If we duplicate it into except, we're repeating ourselves. The right answer is finally:
try: f = open("notes.txt") text = f.read() f.close() except FileNotFoundError: print("Not found.") finally: print("File handled.") # always runs — once
Two more pieces complete the try family. else for "happy path only". finally for "always runs, no matter what".
New Concept · The Full Block
14 minThe full shape
try: # risky code except SomeError: # runs if SomeError was raised else: # runs only if no exception was raised in try finally: # ALWAYS runs — exceptions, returns, breaks, anything
except blocks may be many, but else and finally can each appear at most once.
The order, exactly
Memorise this run-order. It's on every exam Python ever wrote:
Success path: try → else → finally Caught exception: try → except → finally Uncaught exception: try → finally → propagate up Return from try: try → finally → return break/continue: try → finally → break/continue
The key insight: finally always runs, and it always runs before the program leaves the try block — even if a return, break, or exception is on its way out.
Why else?
You could put the happy-path code at the end of the try block — but then any exception it raises would also be caught, which is usually not what you want.
# Without else — the "report" call is also wrapped in the try try: n = int(input("Number: ")) use_it(n) # if this raises, the except catches! except ValueError: print("Bad input.")
# With else — only int() is in the try try: n = int(input("Number: ")) except ValueError: print("Bad input.") else: use_it(n) # exceptions here are NOT caught above
The else block lets you keep the try small and focused on the risky line only.
Why finally?
For cleanup that must always happen — close a file, release a lock, log the action, restore a setting. The with statement handles file closing automatically for you, but the principle generalises:
def transfer(amount): print(f"Starting transfer of {amount}") try: bank.withdraw(amount) bank.deposit(amount) except BankError: print("Transfer failed — rolling back") bank.rollback() finally: bank.unlock() # always release the lock! print("Transfer complete.")
If withdraw blew up, the lock would never release without finally. Now it always does — exception or not.
The surprise · finally beats return
Yes, even a return from inside try waits for finally to run first.
def f(): try: return 1 finally: print("cleanup!") # this prints BEFORE the 1 is returned result = f() # cleanup! print(result) # 1
Some students find this magical. It's the whole point: cleanup is non-negotiable.
Be careful · finally can override return
If you return from finally itself, it replaces whatever the try was going to return. Almost always a bug:
def f(): try: return 1 finally: return 2 # ❌ silently replaces the 1! print(f()) # 2
Rule: don't put return, break or continue inside finally. Use it for side-effects (close, log, unlock) only.
Worked Example · The Audit-Logged Withdrawal
12 minThe story
Build withdraw.py. Every withdrawal must be logged — successful or not. Code:
# withdraw.py — every withdrawal logged, exception or no from datetime import datetime def log(line): with open("audit.log", "a", encoding="utf-8") as f: f.write(f"{datetime.now().isoformat(timespec='seconds')} {line}\n") def withdraw(balance, amount): log(f"ATTEMPT {amount} from {balance}") try: amt = int(amount) if amt <= 0: raise ValueError(f"Non-positive amount: {amt}") if amt > balance: raise ValueError(f"Insufficient funds: have {balance}") except ValueError as e: log(f"FAIL {e}") return None else: log(f"OK new balance {balance - amt}") return balance - amt finally: log(f"DONE transaction closed") print(withdraw(100, 30)) # 70 print(withdraw(100, "fifty")) # None (bad input) print(withdraw(50, 500)) # None (insufficient)
Resulting audit.log
2026-05-27T19:14:02 ATTEMPT 30 from 100 2026-05-27T19:14:02 OK new balance 70 2026-05-27T19:14:02 DONE transaction closed 2026-05-27T19:14:02 ATTEMPT fifty from 100 2026-05-27T19:14:02 FAIL invalid literal for int() with base 10: 'fifty' 2026-05-27T19:14:02 DONE transaction closed 2026-05-27T19:14:02 ATTEMPT 500 from 50 2026-05-27T19:14:02 FAIL Insufficient funds: have 50 2026-05-27T19:14:02 DONE transaction closed
Read the diff
Three things to notice. (1) Every call appends three lines to the log — ATTEMPT, then OK or FAIL, then DONE. The DONE always runs because it's in finally. (2) The else block computes the success log only on the happy path. (3) Even though the function returns from inside both branches, finally still runs first — exactly the "finally beats return" rule from the concept section.
Try It Yourself
13 minWithout running, predict what f(2) and f(0) print.
def f(x): try: print("A try") result = 10 / x print("B try") except ZeroDivisionError: print("C except") else: print("D else") finally: print("E finally") print("F after") f(2) print("---") f(0)
Reveal expected output
A try B try D else E finally F after --- A try C except E finally F after
Happy path: A, B, D, E, F. Sad path: A, C, E, F. The else block runs only when the try block completed without raising.
Wrap a function that prints "Goodbye" in finally — no matter how the function exits (return, exception, normal completion). Test all three cases.
Hint
def hi(x): try: if x == "boom": raise ValueError("oops") if x == "early": return "left early" return "normal" except ValueError: return "caught" finally: print("Goodbye") print(hi("normal")) # Goodbye → normal print(hi("early")) # Goodbye → left early print(hi("boom")) # Goodbye → caught
The print fires every time, before the return value reaches the caller.
Find the subtle bug below, fix it, and explain in a comment.
def double(x): try: return x * 2 finally: return 999
Reveal the fix
def double(x): try: return x * 2 finally: # ❌ BUG — returning from finally silently replaces # whatever the try was going to return. Remove this line. pass # or simply omit the finally block entirely
This trap catches even experienced developers. Rule from the concept section: never return or break inside finally.
Mini-Challenge · The Reliable Save
8 minBuild save_to_file.py. A function safe_save(path, text) that writes text to path and reports clearly what happened.
Requirements:
- The
tryopens the file in"w"mode and writes. - The
exceptcatchesPermissionError,IsADirectoryError, andOSError(the catch-all) — each with a different message. - The
elseblock printsWrote N bytes to PATHon success. - The
finallyblock always prints a one-liner — even on success — saying the save attempt is finished.
Test by calling it with three paths: a fine path, a directory path (e.g. "."), and a normally-OK path inside a folder you make read-only.
Show one possible solution
# save_to_file.py — try / except / else / finally in one block def safe_save(path, text): try: with open(path, "w", encoding="utf-8") as f: n = f.write(text) except PermissionError: print(f" ! No permission to write {path}") except IsADirectoryError: print(f" ! {path} is a directory, not a file") except OSError as e: print(f" ! Disk error: {e}") else: print(f" Wrote {n} bytes to {path}") finally: print(f" (save attempt for {path} finished)") print() safe_save("hello.txt", "hi there!\n") print() safe_save(".", "this won't work") print() safe_save("locked/x.txt", "depends on your filesystem")
Non-negotiables: a try, three different excepts with distinct messages, an else reporting bytes written, and a finally that always prints. Run with all three test paths and confirm the finally line fires every time — even after a success.
Recap
3 minThe full block is try / except / else / finally. else runs only when the try finished without raising — perfect for "happy path" code that you don't want the except to catch. finally always runs — exceptions, returns, breaks, everything — so it's the place for cleanup like closing files or releasing locks. Never put return inside finally — it silently replaces what was being returned. with open(...) already gives you finally-style cleanup for free.
Vocabulary Card
- else block
- The happy-path branch. Runs only when no exception was raised in
try. - finally block
- The cleanup branch. Always runs, even on return or unhandled exception.
- finally beats return
- Cleanup runs before any return/break/continue actually takes effect.
- cleanup code
- Anything that must happen no matter what — close, release, log, restore.
Homework
4 minWrite guess_lifecycle.py. The user guesses a secret number 1-20. The function's job:
- Inside
try: parse the input asintand compare against the secret. - Inside
except ValueError: print "Not a number." - Inside
else: print the win/lose result (compared to the secret). - Inside
finally: append a line toguesses.logrecording the timestamp, raw input, and outcome (one ofWIN,WRONG,BAD).
Loop the function for 5 attempts. After the loop, read guesses.log and print a small stats line: total attempts, wins, wrongs, bads.
Sample · guess_lifecycle.py
# guess_lifecycle.py — every attempt always logged import random from datetime import datetime SECRET = random.randint(1, 20) def attempt(): raw = input("Guess 1-20: ").strip() outcome = "BAD" try: n = int(raw) if n == SECRET: outcome = "WIN" print("🎉 Got it!") else: outcome = "WRONG" print("Nope.") except ValueError: print("Not a number.") outcome = "BAD" finally: with open("guesses.log", "a", encoding="utf-8") as f: stamp = datetime.now().isoformat(timespec="seconds") f.write(f"{stamp} {raw!r:<10} {outcome}\n") return outcome for _ in range(5): if attempt() == "WIN": break # Stats with open("guesses.log", encoding="utf-8") as f: lines = [ln for ln in f if ln.strip()] counts = {"WIN": 0, "WRONG": 0, "BAD": 0} for ln in lines: last = ln.strip().split()[-1] counts[last] = counts.get(last, 0) + 1 print(counts)
Non-negotiables: finally writes the log line every attempt, regardless of which branch ran. The else path is missing here because we're using outcome assignment inside the try — both shapes are valid; check that finally still always logs.