Why this matters
A class is a set of rules about how its objects may be used. A bank account must not go below zero. A port number must be between 1 and 65535. When a caller breaks one of these rules, the class has to say so. The way it says so is part of the class design.
Two tools do this job. One is the exception, a signal that stops normal flow and jumps to a handler somewhere up the call chain. The other is a return value that means "this did not work", such as None. Picking the wrong one makes code hard to read and easy to break. Callers either forget to check for failure, or they wrap ordinary code in try blocks for no reason.
This lesson covers custom exception classes, then the decision between raising and returning. It also shows how one error can be linked to the error that caused it. Object-oriented ideas such as encapsulation and invariants are covered in the earlier lessons of this module, and the next lesson on factories and builders builds on the same classes.
You should already know classes, __init__, and if statements. Knowing what a try block does helps, but the lesson explains it.
What an exception is
An exception is an object that describes a problem. When code creates one and uses raise, the normal flow stops. Python looks for a matching except block in the current function. If there is none, it leaves the function and looks in the caller. It keeps going up until it finds an except block (a handler) or reaches the top of the program. If no handler is found, the program prints a traceback (the list of lines where the error happened) and stops.
A small example makes this concrete. Here is a function that divides two numbers and refuses to divide by zero:
The output is:
5.0
Problem: cannot divide 10 by zeroIf b is zero, the function raises. The line return a / b never runs. The caller decides what to do. The first print prints 5.0. The second call raises, so the except block runs and prints the message. The line after the raise is skipped.
Every exception object has three useful parts. The message is the text you pass to it, and str(err) returns it. The type is the class name, which tells the caller what kind of problem happened. The cause is an optional earlier exception that led to this one. You will see it later in this lesson.
Exceptions in Python are all unchecked
Java splits exceptions into two groups. A checked exception must be declared in the method signature, and the compiler forces every caller to catch it or pass it on. Python has no such rule. Any function can raise any exception, and the compiler never asks the caller to handle it. The only check is the one that happens at run time, when the exception is raised and nothing catches it.
This makes the decision about what to raise more important. Python's signature does not warn a caller about failures. Read the function's docstring, which is the text in triple quotes under the def line, to see what it can raise. Type hints show the types of the values, but they do not list exceptions.
A useful rule still applies. Raise an exception for a value that is simply wrong, such as a negative amount, and the caller should fix its code. Make the exception carry data when the caller can recover, for example by asking the user for a different amount or by trying another account.
Writing a custom exception
A custom exception is a class you write for one kind of problem in your own program. It gives the problem a name that a reader can understand, and it can carry extra data that a plain message cannot. In Python a custom exception inherits from Exception, or from a more specific built-in exception. By convention its name ends in Error, as in ValueError.
A good custom exception has three traits:
- It has a name that says what went wrong, such as
InsufficientFundsError, notMyError. - Its initializer passes a message up to the parent class with
super().__init__(...). - It stores extra data as attributes, when the caller needs that data to recover.
Here is a full example that stores how much money was missing:
The output is:
Withdrew 400. Balance: 600
Caught: Not enough money. Short by 300 cents.
Short by: 300The first withdrawal succeeds and the balance drops to 600. The second withdrawal asks for 900, which is 300 more than the balance. The exception is built with amount_cents - self._balance, which is 300. The caller reads that number from err.short_by and does not have to parse the message text. This is the reason to add an attribute: the caller can act on the number, and a message string alone would force it to guess.
Notice that withdraw changes nothing when it raises. The balance stays at 600. Keep that property in your own classes. When a method fails, leave the object as it was before the call, so the caller can try again safely.
Checking the rules in __init__
The rules for a new object belong in its initializer. If the initializer raises, Python does not create the object, so the caller never receives a half-built account. The next example adds the checks that the earlier version left out.
The output is:
Withdrew 400. Balance: 600
Caught: Not enough money. Short by 300 cents.
Short by: 300
Caught: owner must not be blank
Caught: amount must be positive: -5The try block in the last part has two except blocks for different types. Python checks them in order, and the first one that matches runs. InsufficientFundsError is listed first because it is the more specific type. Put the more specific type first, or the general one will catch the error before the specific handler is reached.
The class InsufficientFundsError is defined after Account, but that is fine. Python looks up a name when the code that uses it runs, not when the class is written. The withdraw method finds the class at the moment it raises.
Exception chaining
Sometimes a low-level error, such as a failed conversion, should be reported to the caller in the words of your class. Python keeps the original error as the cause, using raise ... from. Here is a function that reads a port number from text:
The output is:
8080
Invalid port: "abc"
cause: ValueErrorThe caller sees a message about the port, and it can still find the original error through err.__cause__. The from err part is what links the two. Without it, Python still records the original error as the context, but the traceback text is less clear about which error caused which.
If the exception is not caught, Python prints both errors, with the original one first. The file names and line numbers are left out of the example below, because they depend on where the file is saved. Only the important lines are shown:
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'abc'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ValueError: Invalid port: "abc"Read the traceback from the bottom to see the error that the caller got. Read the first block to see the original problem. The phrase "The above exception was the direct cause" is Python's way of showing a from link.
Raise or return a value
This is the decision that causes the most trouble in class design. The question is simple: when a method cannot do its job, should it raise an exception, or should it return a value that means "nothing found"?
Here is a plain comparison. A raise stops the normal flow. The caller must handle it or let it pass up. A return value completes normally. The caller must check the value to know what happened. Each choice has a cost.
If the method raises, the caller cannot forget about the failure. Once the exception happens, the code after the call does not run. This is good when the failure means the program is in a state it should not continue from. It is also good when many callers would otherwise need to check the same error.
If the method returns a value that means "nothing", the caller has to write a check. If the caller forgets, the program keeps going with a bad value, and the bug may show up much later and far away from its cause. This is good when a failure is normal and expected, for example a search that finds nothing, or a user typing a value that might not parse.
Python's usual way to return "no value" is None. Type hints can say so with Optional, which means "this type, or None". Here is the same port parser written to return None instead of raising:
The output is:
8080
None
None
80The caller checks for None with is not None, or supplies a fallback with a conditional expression. It does not need a try block at all. Notice that None loses the reason for the failure: both "not a number" and "out of range" look the same. If the caller needs to show the user why the input was wrong, the raising version above is the better fit.
A simple rule for choosing
Use these questions in order:
- Is the failure expected in normal use, such as a user typing a wrong value or a lookup for something that may not exist? If yes, return
Noneor another value that means "nothing", and document it withOptionalin the type hint. - Is the input simply invalid, which means the caller made a mistake? If yes, raise
ValueErrororTypeError. The fix is in the caller's code. - Can the caller recover, and does it need extra data to do so? If yes, raise a custom exception with attributes.
- Does the method leave the object in a broken state if it fails? If yes, check every rule before changing anything, so the raise happens before any change.
These rules are guidelines, not laws. Read the existing code in your project and follow its style when it already has one. Consistency inside a project matters more than any single rule.
Keep the object valid when raising
Here is a common pattern that goes wrong. A method changes one attribute, then raises halfway through. The object is now in a state no caller expected. The safe pattern checks everything first:
The output is:
Caught: not enough money
100 5All checks happen before any attribute changes. If either check raises, both accounts keep their old balances.
A worked example: a small bank account
This example combines the ideas. The account validates its own rules in __init__, uses a custom exception for a condition the caller can recover from, uses ValueError for bad arguments, and leaves the balance unchanged when a withdrawal fails.
The output is the same as the earlier example: the first withdrawal works, the second raises with the shortfall, the blank owner is refused, and the negative amount is refused.
finally is the last piece. A finally block runs whether or not an exception was raised. It is useful for cleanup, such as closing a file. Python also has an else block for a try, which runs only when no exception was raised. The next example shows both, together with a custom exception that records what happened:
The output is:
['InsufficientFundsError', 'finally ran']
else ran with 5
finally always runsThe finally block ran after the except block in the first part, and after the else block in the second part. The else block ran because the conversion did not raise.
Common mistakes
Most mistakes in this area come from a few habits. Some of the mistakes below show the real message that Python printed, so you can recognise them.
Catching the exception and doing nothing
An empty except block hides the problem. The program keeps going as if nothing happened, and the bug appears somewhere else later. Log the problem, raise it again, or return a value that says what happened. Do not swallow it silently.
The output is None. The function returned nothing, and the caller has no hint that the text was wrong. The fix is to raise an error, or to return a value that the caller is required to check.
Catching a general exception and hiding the real one
A bare except: or except Exception: catches everything, including mistakes in your own code, such as a misspelled name. Catch the specific type that you expect. The next example shows how a typo in the code is hidden:
The output is 0. The name prces is misspelled, so Python raises NameError. The broad except catches it and returns 0, which hides the bug. Without the except, Python would show NameError: name 'prces' is not defined, which points straight to the typo.
An uncaught exception stops the program
An exception that no one catches stops the program. A check in __init__ that rejects bad input is a good place for an error, because the object was never created. Here is the real output, shortened, when such a check fails and nothing catches it:
Traceback (most recent call last):
...
ValueError: balance must not be negative: -3The last line says what went wrong. The lines above it show where. Read the last line first, then the line of your code that the traceback names.
Raising from the middle of a change
A method that changes an attribute and then raises leaves the object half updated. Check all rules before the first change, as the transfer example earlier showed.
Using an exception for a normal result
A search that finds nothing is not an error. Raising an exception for it makes every search look like a failure in the logs. Return None for normal "not found" results, and document it with Optional in the type hint.
Debugging walkthrough: finding where a value was refused
Suppose a program stops with an error that you did not expect, and the message names a value. Use this routine.
- Read the last line of the traceback. Note the exception type and the message.
- Find the first line of your own code in the traceback, usually the one just above the error. That is the place where the exception was raised.
- Look at the check on the line above the raise. Did the value meet the rule you thought? Print the value just before the check.
- If the exception has a cause, the traceback shows it above the final error. Read that block to find the original problem.
- Fix the caller that passed the bad value, unless the check itself is wrong.
Variations and edge cases
Python lets an exception carry any number of attributes, which makes it useful for returning more than one value to the caller. Keep the attributes simple, such as numbers and text.
A function can raise a different exception type from the one it caught. The from keyword links the new one to the old one, as the port example showed. Without from, the new exception still records the old one as its context, but it is less clear.
Python 3.11 and later have ExceptionGroup, which holds several exceptions at once. This is used by code that runs several tasks and collects their failures. Most classes do not need it.
Edge case: an exception raised inside a finally block replaces the one that was already in flight. Keep finally blocks short, and do not raise from them.
Interview-style questions
What is the difference between raising and returning None? A raise stops the normal flow, and the caller must handle it. Returning None completes normally, and the caller must check the value. Use a raise for a mistake, and None for a normal "nothing found".
Does Python have checked exceptions? No. Any function can raise any exception, and the caller is not forced to handle it. Read the docstring to find what a function can raise.
What does raise ... from err do? It sets err as the cause of the new exception. The traceback then shows both errors, and the new one's __cause__ attribute holds the old one.
Why should a custom exception store data? So the caller can act on the data without parsing the message. A number such as the amount short is more useful than text.
Why should a method check its rules before it changes anything? If it raises after a change, the object is left half updated, and the caller cannot trust it. Checking first keeps the object as it was.
Real-world uses
A payments library raises a custom error that holds the card decline code, so the checkout page can show a specific message. A file parser raises an error that holds the line number of the problem, so the user can fix the file. A search function returns None when no record matches, because an empty result is an ordinary answer. A web framework turns an exception into an HTTP status code, using the exception's type to decide the response.
Practice
Each exercise is followed by its answer. Try to answer before you read the answer.
Exercise 1
A Playlist class has a method remove_song(position). The position must be between 0 and the number of songs minus one. Should the method raise, or return a value? Which exception type should it raise?
Show the answer
It should raise. A position outside the range is a mistake in the caller's code, so use IndexError. Check the range first, before removing anything, so the playlist is unchanged when the check fails.
Exercise 2
Write a custom exception NotFoundError that stores the name of the missing item and passes a message to the parent class.
Show the answer
The initializer passes the message to the parent class, and the attribute keeps the name so the caller can read it. The output is Not found: song.mp3 | song.mp3.
Exercise 3
A function find_user_by_email(users, email) looks up a user in a list of dictionaries and may find none. Should it raise, or return None? Explain why in one sentence.
Show the answer
It should return None, because not finding a user is a normal result. Users may type addresses that do not exist, and this is not a bug in the program.
Exercise 4
The following code runs. What does it print, and why does the except block never run?
Show the answer
It prints start and nothing else. The try block never raises, so the except block never runs. The except block runs only when the try block raises a matching exception.
Exercise 5
Explain what the chained error tells you in a traceback with the words "The above exception was the direct cause of the following exception", and where you should start reading.
Show the answer
The text means that the error at the bottom was raised with from a different, earlier error. The earlier error is shown first, above the sentence. Read the block at the top to find the original problem, and read the block at the bottom to see the error that the caller received.
Summary
- An exception stops normal flow and jumps to a matching
exceptblock. A return value ofNonecompletes normally, and the caller checks it. - Python has no checked exceptions. Any function can raise any exception, so read the docstring to find out what it can raise.
- A custom exception inherits from
Exception, passes its message to the parent withsuper().__init__, and stores any data the caller needs. - Raise for invalid input and for failures the caller must act on. Return
None, withOptionalin the type hint, for normal "no result" cases. - Use
raise ... from errto keep the original error as the cause. - Check every rule before changing an object, so a failed call leaves the object as it was.