8.1. Take Advantage of Each Block in try/except/else/finally¶
There are four distinct times when you might want to take action during exception handling in Python. These are captured in the functionality of try, except, else, and finally blocks. Each block serves a unique purpose in the compound statement, and their various combinations are useful (see Item 87: “Define a Root Exception to Insulate Callers from APIs” for another example).
finally Blocks Use try/finally when you want exceptions to propagate up but also want to run cleanup code even when exceptions occur. One common usage of try/finally is for reliably closing file handles (see Item 66: “Consider contextlib and with Statements for Reusable try/finally Behavior” for another—likely better—approach):
Click here to view code image
>>> def try_finally_example(filename):
>>> print('* Opening file')
>>> handle = open(filename, encoding='utf-8') # Maybe OSError
>>> try:
>>> print('* Reading data')
>>> return handle.read() # Maybe UnicodeDecodeError
>>> finally:
>>> print('* Calling close()')
>>> handle.close() # Always runs after try block
Any exception raised by the read method will always propagate up to the calling code, but the close method of handle in the finally block will run first:
Click here to view code image
filename = 'random_data.txt'
- with open(filename, 'wb') as f:
f.write(b'xf1xf2xf3xf4xf5') # Invalid utf-8
data = try_finally_example(filename)
>>>
* Opening file
* Reading data
* Calling close()
Traceback ...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in
➥position 0: invalid continuation byte
You must call open before the try block because exceptions that occur when opening the file (like OSError if the file does not exist) should skip the finally block entirely:
Click here to view code image
try_finally_example(‘does_not_exist.txt’)
Opening file Traceback … FileNotFoundError: [Errno 2] No such file or directory: ➥‘does_not_exist.txt’
else Blocks
Use try/except/else to make it clear which exceptions will be handled by your code and which exceptions will propagate up. When the try block doesn’t raise an exception, the else block runs. The else block helps you minimize the amount of code in the try block, which is good for isolating potential exception causes and improves readability. For example, say that I want to load JSON dictionary data from a string and return the value of a key it contains:
Click here to view code image
>>> import json
>>>
>>> def load_json_key(data, key):
>>> try:
>>> print('* Loading JSON data')
>>> result_dict = json.loads(data) # May raise ValueError
>>> except ValueError as e:
>>> print('* Handling ValueError')
>>> raise KeyError(key) from e
>>> else:
>>> print('* Looking up key')
>>> return result_dict[key] # May raise KeyError
In the successful case, the JSON data is decoded in the try block, and then the key lookup occurs in the else block:
Click here to view code image
>>> assert load_json_key('{"foo": "bar"}', 'foo') == 'bar'
* Loading JSON data
* Looking up key
If the input data isn’t valid JSON, then decoding with json.loads raises a ValueError. The exception is caught by the except block and handled:
Click here to view code image
load_json_key('{"foo": bad payload', 'foo')
>>>
* Loading JSON data
* Handling ValueError
Traceback ...
JSONDecodeError: Expecting value: line 1 column 9 (char 8)
The above exception was the direct cause of the following ➥exception:
Traceback ... KeyError: 'foo'
If the key lookup raises any exceptions, they propagate up to the caller because they are outside the try block. The else clause ensures that what follows the try/except is visually distinguished from the except block. This makes the exception propagation behavior clear:
Click here to view code image
load_json_key('{"foo": "bar"}', 'does not exist') >>> * Loading JSON data * Looking up key Traceback ... KeyError: 'does not exist' Everything Together
Use try/except/else/finally when you want to do it all in one compound statement. For example, say that I want to read a description of work to do from a file, process it, and then update the file in-place. Here, the try block is used to read the file and process it; the except block is used to handle exceptions from the try block that are expected; the else block is used to update the file in place and allow related exceptions to propagate up; and the finally block cleans up the file handle:
Click here to view code image
>>> UNDEFINED = object()
>>>
>>> def divide_json(path):
>>> print('* Opening file')
>>> handle = open(path, 'r+') # May raise OSError
>>> try:
>>> print('* Reading data')
>>> data = handle.read() # May raise UnicodeDecodeError
>>> print('* Loading JSON data')
>>> op = json.loads(data) # May raise ValueError
>>> print('* Performing calculation')
>>> value = (
>>> op['numerator'] /
>>> op['denominator']) # May raise ZeroDivisionError
>>> except ZeroDivisionError as e:
>>> print('* Handling ZeroDivisionError')
>>> return UNDEFINED
>>> else:
>>> print('* Writing calculation')
>>> op['result'] = value
>>> result = json.dumps(op)
>>> handle.seek(0) # May raise OSError
>>> handle.write(result) # May raise OSError
>>> return value
>>> finally:
>>> print('* Calling close()')
>>> handle.close() # Always runs
In the successful case, the try, else, and finally blocks run:
Click here to view code image
>>> temp_path = 'random_data.json'
>>>
>>> with open(temp_path, 'w') as f:
>>> f.write('{"numerator": 1, "denominator": 10}')
>>>
>>> assert divide_json(temp_path) == 0.1
* Opening file
* Reading data
* Loading JSON data
* Performing calculation
* Writing calculation
* Calling close()
If the calculation is invalid, the try, except, and finally blocks run, but the else block does not:
Click here to view code image
>>> with open(temp_path, 'w') as f:
>>> f.write('{"numerator": 1, "denominator": 0}')
>>>
>>> assert divide_json(temp_path) is UNDEFINED
* Opening file
* Reading data
* Loading JSON data
* Performing calculation
* Handling ZeroDivisionError
* Calling close()
If the JSON data was invalid, the try block runs and raises an exception, the finally block runs, and then the exception is propagated up to the caller. The except and else blocks do not run:
Click here to view code image
- with open(temp_path, 'w') as f:
f.write('{"numerator": 1 bad data')
divide_json(temp_path)
>>>
* Opening file
* Reading data
* Loading JSON data
* Calling close()
Traceback ...
JSONDecodeError: Expecting ',' delimiter: line 1 column 17
➥(char 16)
This layout is especially useful because all of the blocks work together in intuitive ways. For example, here I simulate this by running the divide_json function at the same time that my hard drive runs out of disk space:
Click here to view code image
- with open(temp_path, 'w') as f:
f.write('{"numerator": 1, "denominator": 10}')
divide_json(temp_path)
>>>
* Opening file
* Reading data
* Loading JSON data
* Performing calculation
* Writing calculation
* Calling close()
Traceback ...
OSError: [Errno 28] No space left on device
When the exception was raised in the else block while rewriting the result data, the finally block still ran and closed the file handle as expected.
8.1.1. Things to Remember¶
✦ The try/finally compound statement lets you run cleanup code regardless of whether exceptions were raised in the try block.
✦ The else block helps you minimize the amount of code in try blocks and visually distinguish the success case from the try/except blocks.
✦ An else block can be used to perform additional actions after a successful try block but before common cleanup in a finally block.