A Python exception is the language telling you that an operation could not finish as asked. The try/except/else/finally block is how you decide what to do about it: catch the ones you can handle, let the rest crash loudly, and make sure cleanup happens either way.
Here is the shape in full. try holds the code that might fail. except catches specific exception types and runs a handler. else runs only if the try block completed without raising. finally runs no matter what, after except and else have had their turn. raise is the verb you use to signal an error yourself, whether you caught something and want to re-throw it with a better message, or you are validating input and the data is simply wrong.
What does each clause of the block actually do?
The order is not cosmetic — Python evaluates it in a fixed sequence, and the differences between else and finally are the part people mix up most.
| Clause | Runs when | Typical job |
|---|---|---|
try | always | the risky work |
except | only if a matching error was raised | recover, translate, or re-raise |
else | only if try succeeded with no exception | the success path, kept separate from the try body |
finally | always, last | cleanup: close files, release locks, log |
def risky(value):
if value < 0:
raise ValueError("value must not be negative")
return value * 2
for probe in (5, -1):
try:
print("result:", risky(probe))
except ValueError as exc:
print("caught:", exc)
else:
print("no error on this pass")
finally:
print("finally runs every time")
Look closely at the output order. The else block runs before the finally block, and when the try body raises, else is skipped entirely while finally still runs.
How do you catch only the errors you expect?
Catch by exception type, and catch the narrowest type that covers your situation. except ValueError says "I know exactly which failure this is" and lets everything else propagate, which is what you want, because an unexpected exception escaping to the top level is how you find out about a bug.
Catching Exception is a deliberate decision, not a default. It is the right call when you genuinely want to handle any ordinary failure — a network call, a database query, a third-party library — and produce a uniform error message. It is the wrong call when it hides a TypeError that means your own code is broken.
def parse(text):
return int(text)
for probe in ("42", "abc"):
try:
print("parsed:", parse(probe))
except ValueError:
print("not a number:", probe)
except TypeError:
print("wrong type:", probe)
except Exception:
print("something else entirely")
The as keyword binds the raised exception to a name so you can read its message. That name is deleted when the handler ends, so do not rely on it afterwards. Use str(exc) for a message, repr(exc) for the full type and arguments, and exc.args for the raw tuple that was passed to the constructor.
How do you raise an error yourself, and how do you re-raise one?
raise SomeError("message") signals that the current operation cannot continue. It is the right move whenever a function receives input it cannot handle, or detects a state that makes continuing pointless. The exception type should tell the caller what kind of problem it is; the message should tell a human what went wrong.
Inside an except block, a bare raise re-raises the exception that is being handled, preserving the original traceback. If you want to add context first, catch the original, and raise a new one.
class ConfigError(Exception):
pass
def load(path):
with open(path, encoding="utf-8") as handle:
for line in handle:
if "=" not in line:
raise ConfigError(f"missing '=' in {line!r}")
try:
load("settings.ini")
except OSError as exc:
problem = ConfigError(f"cannot read settings.ini: {exc}")
print("translated:", problem)
print("original was:", type(exc).__name__)
Raising a new exception inside an except block but leaving out the from keyword still records the original as the implicit context, so the traceback shows both errors — but the link between them is implicit. The from keyword makes it explicit, which is the subject of the next lesson.
Why should the success path go in else instead of in try?
Putting everything in the try block means that a bug in your success path is caught by your own error handler and reported as if it were a failure of the risky operation. The else clause keeps the danger zone narrow: only the genuinely risky call is protected.
def only_the_risky_call_is_protected():
try:
value = int("42")
except ValueError:
print("never reached")
else:
result = value * 2
print("success path lives in else:", result)
only_the_risky_call_is_protected()
The same reasoning applies to finally. It is the right home for cleanup that must happen on every path — closing a file, releasing a lock, rolling back a transaction — and the wrong home for anything that can itself raise, because an exception inside finally replaces the exception you were already handling.
What is the difference between raise and assert?
assert is for internal invariants: "if this is false, the code below is broken and I want to know immediately". It is skipped entirely when Python runs with the -O optimisation flag, so it must never be used for validating user input or data from a file. raise is the mechanism for real error handling and it always runs.
One more thing to know: exceptions are for exceptional situations, not for ordinary branching. If a function returns a value you are going to check fifty different ways, returning that value is clearer than raising and catching an exception to carry it.
Recap
elseruns only on success;finallyruns on every path, last.- Catch the narrowest exception type you can handle, and order handlers from specific to general.
except Exceptionis a decision — use it when you really do mean "any ordinary failure".- Use
raiseto report invalid input or impossible states; use a bareraiseto re-raise unchanged. assertdocuments invariants and vanishes under-O. Never validate user data with it.
Try it yourself: a safe config loader
Write a function load_config(path) that reads a small text file and returns the parsed key/value pairs as a dictionary. It should raise a ValueError when a line has no = in it, and let FileNotFoundError travel to the caller untouched.
Now write the caller. It should catch FileNotFoundError and print a friendly message, catch ValueError and print the offending line number, and use a finally block to print a "config load finished" marker regardless of the outcome. Then deliberately break a line in your test file and confirm each handler fires for the right reason.