Files and exceptions

Most programs that do real work touch the filesystem: reading a config, writing results, loading data. And when things go wrong, Python raises an exception, a signal that something unexpected happened. This chapter covers both: getting data in and out of files, and writing code that handles errors gracefully instead of crashing.
Opening files
open() opens a file and returns an object you can read from or write to. You tell it the path and what you want to do with the file (read, write, or append). Always close a file when you are done; the with statement does this automatically.
f = open("data.txt", "r") # "r" = read
content = f.read()
f.close()The "r" is the mode:
| Mode | Meaning |
|---|---|
"r" | Read. File must exist. Default mode. |
"w" | Write. Creates or overwrites the file. |
"a" | Append. Adds to the end without erasing. |
"x" | Create. Fails if the file already exists. |
"r+" | Read and write. |
"b" | Binary. Add to any mode: "rb", "wb". |
Always call .close() when you are done. Forgetting it leaves the file open and can lose data that was still waiting to be written. The reliable way to handle this is the with statement.
open(path, mode) hands you a file object, and the mode says what you want to do: "r" to read, "w" to write (which wipes whatever was there), "a" to add to the end. I lost a file to "w" once by reaching for it when I meant "a", so picture the mode before you type it. The with statement
with open(...) manages the file for you, closing it automatically when the indented block finishes, even if an error happens. Always use with open(...) instead of manual open()/close(). It is safer and it is the standard.
with open("data.txt", "r") as f:
content = f.read()
# f is closed here, guaranteedwith is Python's context manager syntax: it runs setup and teardown code for you, here opening the file and reliably closing it. You do not need to know how it works internally to use it with open().
with open(...) as f opens the file, lets you work with f inside the indented block, then closes it for you when the block ends, even if something goes wrong partway through. Reach for it every time instead of calling .close() yourself, it is one less thing to forget. Reading files
Three methods for reading. .read() loads the entire file as one string. .readline() reads one line. Iterating directly over the file object reads line by line, which is the most efficient approach for large files since it does not load everything into memory at once.
with open("data.txt", "r") as f:
content = f.read() # entire file as one string
with open("data.txt", "r") as f:
first_line = f.readline() # one line at a time
with open("data.txt", "r") as f:
lines = f.readlines() # list of lines, each ending in "\n"For large files, reading line by line is more efficient than loading everything at once:
with open("big_file.txt", "r") as f:
for line in f: # iterate the file directly, memory-efficient
print(line.strip()) # strip() removes the trailing newlineIterating directly over the file object (for line in f) is the most efficient way to read a large file.
.read() gives you the whole file as one string, .readline() gives you a single line. When the file might be big, loop with for line in f instead, it reads one line at a time so you never load the lot into memory. The .strip() on each line clears the trailing newline. Writing files
"w" mode overwrites the file entirely if it exists. "a" mode adds to the end. .write() does not add a newline automatically; include "\n" explicitly at the end of each line. To write multiple lines at once, join them with "\n".join().
with open("output.txt", "w") as f:
f.write("Hello, world\n")
with open("output.txt", "a") as f:
f.write("Another line\n")"w" overwrites the file entirely if it exists. "a" adds to the end.
f.write() does not add a newline automatically, so include "\n" explicitly. To write multiple lines at once:
lines = ["Line one", "Line two", "Line three"]
with open("output.txt", "w") as f:
f.write("\n".join(lines) + "\n")"w" creates or overwrites the file, "a" adds to the end without wiping it. .write() does not add a newline for you, so tack "\n" onto each line yourself. For a batch of lines, "\n".join(lines) stitches them together first. Exceptions
When Python hits a problem it cannot handle, it raises an exception: an error that describes what went wrong and where. If you do not handle it, your program crashes and prints a traceback. The table below shows the most common exceptions you will encounter.
Common exceptions you will encounter:
| Exception | When it happens |
|---|---|
FileNotFoundError | open() cannot find the file |
ValueError | Function gets a value of the right type but wrong content, e.g. int("abc") |
TypeError | Wrong type entirely, e.g. "hello" + 5 |
KeyError | Dictionary key does not exist |
IndexError | List index out of range |
ZeroDivisionError | Division by zero |
AttributeError | Object does not have that attribute or method |
FileNotFoundError, ValueError, KeyError, TypeError. Left unhandled they stop the program and print a traceback, which looks alarming but is really a map pointing straight at where it broke. try / except
Wrap code that might fail in a try block. If an exception occurs, the matching except block handles it instead of crashing. Be specific about which exception you catch: catching everything with a bare except: hides real bugs.
try:
value = int("abc")
except ValueError:
print("That's not a valid number")Be specific about which exception you catch. Catching all exceptions with a bare except: hides bugs:
# bad, catches everything including programmer mistakes
try:
result = do_something()
except:
pass
# good, only catches what you expect and can actually handle
try:
result = do_something()
except FileNotFoundError:
print("File not found")try block, and the matching except runs instead of the program crashing. Name the exception you expect, like ValueError, rather than a bare except:, which catches everything and hides real bugs from you. Catch what you can actually deal with, and let the rest show. Catching multiple exceptions
You can handle different error types in separate except blocks, or catch several types in one block using a tuple. The as e part gives you access to the error message.
try:
data = int(user_input)
result = 100 / data
except ValueError:
print("Not a number")
except ZeroDivisionError:
print("Can't divide by zero")Or catch multiple in a tuple:
except (ValueError, ZeroDivisionError) as e:
print(f"Input error: {e}")as e binds the exception object to a name so you can inspect the message.
except block, and Python runs the first one that matches. To handle a few the same way, list them in a tuple: except (ValueError, ZeroDivisionError). The as e part hands you the error object so you can print its message. else and finally
else runs only if no exception occurred. finally always runs, whether or not there was an exception. finally is useful for cleanup that must happen no matter what.
try:
with open("data.txt") as f:
content = f.read()
except FileNotFoundError:
print("File not found, using defaults")
content = ""
else:
print("File loaded successfully")
finally:
print("Done attempting to load file") # always runsfinally is most useful for cleanup (closing connections, releasing locks) even when you are already using with for files.
else runs only when the try raised nothing, a tidy place for the rest of the success path. finally runs every time, exception or not, which makes it the spot for cleanup that has to happen whatever the outcome. With files with already covers the cleanup, so you will reach for finally less than you might expect. raise
You can raise exceptions yourself with raise. This is how you make your functions signal problems clearly to callers instead of silently returning a wrong value.
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / bThis makes your functions explicit about what they expect and signals problems clearly to callers.
raise ExceptionType("message") lets your own code flag a problem out loud instead of carrying on with a bad value. It is how a function says "I cannot work with this" so the caller deals with it. Clear and early beats a wrong answer that shows up much later. Custom exception classes
For larger programs, you can define your own exception types by inheriting from Exception. This lets callers catch your specific errors separately from other kinds of errors.
class InsufficientFundsError(Exception):
pass
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(
f"Cannot withdraw {amount}, balance is {self.balance}"
)
self.balance -= amounttry:
account.withdraw(1000)
except InsufficientFundsError as e:
print(f"Transaction declined: {e}")Exception, and a single empty class body is enough to start. It gives callers a name to catch that is yours, separate from built-in errors, so your program's problems stand on their own. JSON
JSON is the format that everything speaks: APIs, config files, data exports. Python's json module handles it directly. json.load() reads JSON from a file into a Python dictionary or list. json.dump() writes a dictionary or list back to a file as JSON.
Read JSON from a file:
import json
with open("config.json", "r") as f:
config = json.load(f) # parses JSON into a Python dict/list
print(config["setting"])Write JSON to a file:
import json
data = {"name": "Alice", "score": 87, "active": True}
with open("output.json", "w") as f:
json.dump(data, f, indent=2) # indent= makes it human-readableJSON to Python type mapping:
| JSON | Python |
|---|---|
object {} | dict |
array [] | list |
string "" | str |
| number | int or float |
true / false | True / False |
null | None |
To convert between JSON strings and Python objects without touching a file:
import json
# string to Python
data = json.loads('{"name": "Alice", "score": 87}')
# Python to string
text = json.dumps({"name": "Alice", "score": 87}, indent=2)json.load() reads from a file object. json.loads() (with an "s") reads from a string.
json.load(f) turns JSON in a file into Python dicts and lists, json.dump(data, f) writes them back out. The s versions, json.loads and json.dumps, do the same for a string instead of a file. Add indent=2 when you want the output readable. In practice
A save/load pattern for a small game: write state to JSON, load it back on the next run, and fall back to defaults if no save file exists yet:
import json
SAVE_FILE = "save_game.json"
def save_game(player_data: dict) -> None:
with open(SAVE_FILE, "w") as f:
json.dump(player_data, f, indent=2)
print("Game saved.")
def load_game() -> dict:
try:
with open(SAVE_FILE, "r") as f:
return json.load(f)
except FileNotFoundError:
print("No save file found, starting fresh.")
return {"name": "Player", "score": 0, "level": 1}
state = load_game()
state["score"] += 50
save_game(state)
