Resource Safety: finally and with
James opens a JSON file of SmartNotes data. He calls open("notes.json"), reads the content, and forgets to close the file. The program works fine. He runs it again in a loop, opening the same file hundreds of times without closing any of them. Eventually, the operating system refuses to open more files.
"Every open file consumes a resource," Emma says. "If your code crashes between open() and close(), the file stays open. The with statement guarantees the file gets closed, even if an exception occurs."
James thinks back. In Chapter 52, he used with pytest.raises(ValueError): to test exceptions. In Chapter 43, he saw with open(...) in AI-generated code examples. He understood the syntax but not the mechanism. Now the pieces connect.
When you open a file, the operating system allocates a resource (a "file handle") to track that open file. If you forget to close the file, or if your program crashes before the close happens, the resource leaks. The with statement is Python's way of guaranteeing that resources are cleaned up, no matter what happens.
Python's with statement is similar to Java's try-with-resources, C#'s using statement, or Go's defer. It calls __enter__ when the block starts and __exit__ when it ends (even on exceptions). You do not need to understand the __enter__/__exit__ protocol right now; the important thing is that with guarantees cleanup.
The Problem: Files Left Open
Here is the manual way to open and close a file:
f = open("notes.txt")
content: str = f.read()
f.close()
This works when everything goes right. But if f.read() raises an exception, f.close() never runs and the file stays open. You could fix this with try/finally from Lesson 3:
f = open("notes.txt")
try:
content: str = f.read()
finally:
f.close()
This guarantees the file gets closed. But it is verbose. Python offers a cleaner way.
The with Statement
The with statement combines the open and the guaranteed close into one construct:
with open("notes.txt") as f:
content: str = f.read()
# f is automatically closed here, even if f.read() crashes
This does exactly the same thing as the try/finally version above, but in two lines instead of four. The file is opened when the with block starts and closed when it ends, regardless of whether an exception was raised.
withIn Chapter 52, you used with pytest.raises(ValueError): to test exceptions. Now you see the same pattern with files. The with keyword creates a managed context that guarantees cleanup. pytest.raises cleans up exception tracking. open cleans up the file handle. The mechanism is the same: enter the block, do your work, exit the block with cleanup guaranteed.
Loading JSON with json.load
SmartNotes data is stored as JSON. You have seen json referenced in Chapter 43, Lesson 6 in read-only examples. Now you use it yourself:
import json
with open("notes.json") as f:
notes: list[dict[str, str]] = json.load(f)
print(f"Loaded {len(notes)} notes")
json.load(f) reads the file object f and parses the JSON content into Python objects. A JSON array becomes a list. A JSON object becomes a dict. The type annotation list[dict[str, str]] tells the reader (and the type checker) what shape to expect.
Handling FileNotFoundError
What if the file does not exist? open("missing.json") raises FileNotFoundError:
import json
def load_notes(path: str) -> list[dict[str, str]]:
"""Load notes from a JSON file.
Raises:
FileNotFoundError: If the file does not exist.
"""
with open(path) as f:
return json.load(f)
You can catch this at the call site:
try:
notes: list[dict[str, str]] = load_notes("notes.json")
except FileNotFoundError:
print("Notes file not found. Starting with empty list.")
notes = []
The function itself does not catch the error; it lets the caller decide what to do. The docstring documents that FileNotFoundError can be raised.
Handling json.JSONDecodeError
What if the file exists but contains invalid JSON? json.load raises json.JSONDecodeError:
import json
def load_notes_safe(path: str) -> list[dict[str, str]]:
"""Load notes from a JSON file, handling common errors.
Returns an empty list if the file is missing or contains invalid JSON.
"""
try:
with open(path) as f:
data: list[dict[str, str]] = json.load(f)
return data
except FileNotFoundError:
print(f"File not found: {path}")
return []
except json.JSONDecodeError as e:
print(f"Invalid JSON in {path}: {e}")
return []
Two exception types, two except clauses, one return value for each error case. The function degrades gracefully instead of crashing.
Testing File Operations
Test all three paths: success, missing file, and bad JSON:
import json
import pytest
from pathlib import Path
def test_load_notes_success(tmp_path: Path) -> None:
notes_file: Path = tmp_path / "notes.json"
notes_file.write_text('[{"title": "Test", "body": "Hello"}]')
result: list[dict[str, str]] = load_notes_safe(str(notes_file))
assert len(result) == 1
assert result[0]["title"] == "Test"
def test_load_notes_missing_file(tmp_path: Path) -> None:
result: list[dict[str, str]] = load_notes_safe(str(tmp_path / "nope.json"))
assert result == []
def test_load_notes_bad_json(tmp_path: Path) -> None:
bad_file: Path = tmp_path / "bad.json"
bad_file.write_text("{broken json content")
result: list[dict[str, str]] = load_notes_safe(str(bad_file))
assert result == []
The tmp_path fixture (provided by pytest) creates a temporary directory for each test. This avoids polluting your project with test files. Each test creates the file it needs, runs the function, and checks the result.
Comparing try/finally with with
Both approaches guarantee cleanup. The with statement is shorter and less error-prone:
| Approach | Lines | Guarantees Close | Risk of Forgetting |
|---|---|---|---|
Manual f.close() | 3 | No (skipped on exception) | High |
try/finally | 4 | Yes | Low (but verbose) |
with open(...) | 2 | Yes | None (automatic) |
Use with for files. Reserve try/finally for resources that do not support the with statement (which is rare in standard Python).
PRIMM-AI+ Practice: File Loading
Predict [AI-FREE]
Look at this code without running it. For each scenario, predict the output. Write your predictions and a confidence score from 1 to 5 before checking.
import json
def load_config(path: str) -> dict[str, str]:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
print("NOT FOUND")
return {}
except json.JSONDecodeError:
print("BAD JSON")
return {}
# Scenario 1: file exists with valid JSON {"name": "test"}
result1 = load_config("valid.json")
# Scenario 2: file does not exist
result2 = load_config("missing.json")
# Scenario 3: file exists but contains "not json at all"
result3 = load_config("broken.json")
Check your predictions
Scenario 1: Returns {"name": "test"}. No output printed. The file opens, JSON parses, and the function returns the dict directly from the try block.
Scenario 2: Prints NOT FOUND. Returns {}. The open("missing.json") raises FileNotFoundError, caught by the first except clause.
Scenario 3: Prints BAD JSON. Returns {}. The file opens successfully, but json.load raises json.JSONDecodeError because the content is not valid JSON. Caught by the second except clause.
If you predicted all three correctly, you understand how with open interacts with exception handling.
Run
Create three test files in a temporary folder: valid.json (with {"name": "test"}), and broken.json (with not json at all). Do not create missing.json. Write the function and the three calls. Run uv run python file_practice.py and compare output to your predictions.
Investigate
Add a fourth scenario: a file that contains [] (empty JSON array). Predict the return type and value. After running, check whether the function returns an empty list or an empty dict. What does this tell you about the type annotation?
Modify
Change the function to raise the exceptions instead of returning empty dicts. Write pytest.raises tests for both error cases.
Hint
Remove the except blocks entirely (let exceptions propagate), or re-raise with raise inside each except block after logging. The tests would use with pytest.raises(FileNotFoundError) and with pytest.raises(json.JSONDecodeError).
Make [Mastery Gate]
Without looking at any examples, write a function called load_tags(path: str) -> list[str] that:
- Opens the file at
pathwithwith open - Parses the JSON with
json.load - Returns the result (expected to be a list of strings)
- Catches
FileNotFoundErrorand returns an empty list - Catches
json.JSONDecodeErrorand returns an empty list
Write three tests using tmp_path:
test_load_tags_valid: creates a file with["python", "notes"], verifies the function returns["python", "notes"]test_load_tags_missing: passes a non-existent path, verifies empty listtest_load_tags_bad_json: creates a file with"not json", verifies empty list
Run uv run pytest to verify all tests pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Check Your Understanding
I just learned about the with statement in Python for
opening files. Here is my understanding: "with open just
opens the file. You still need to call f.close() at the
end." Is my summary accurate? What am I getting wrong?
Read the AI's response. Did it correct the claim? The whole point of with is that you do NOT need to call f.close(). The with statement handles closing automatically. Compare the AI's explanation to the comparison table in this lesson.
Prompt 2: Generate and Review
Write a Python function that loads a JSON configuration
file and returns it as a dict. Handle the case where the
file does not exist and the case where the JSON is
invalid. Use with open, json.load, type annotations on
all variables, and include a docstring.
Review the AI's output. Check: does it use with open (not manual open/close)? Does it catch FileNotFoundError and json.JSONDecodeError separately? Does the docstring document the exceptions? If anything looks wrong, tell the AI what to fix.
What you're learning: You are applying the resource safety patterns from this lesson to evaluate AI-generated file handling code.
Prompt 3: Compare Patterns
Show me a Python function that reads a file using
try/finally with manual close, and then show the same
function using with open. Explain why the with version
is preferred.
Compare the AI's two versions. Count the lines in each. Verify that the AI correctly states that with guarantees cleanup even on exceptions, just like try/finally but with less code.
What you're learning: You are reinforcing the try/finally vs with comparison from this lesson.
Key Takeaways
-
with openguarantees the file is closed. Even if an exception occurs inside the block, the file handle is released. This replaces the verbosetry/finallypattern. -
json.load(f)parses JSON from a file object. It returns Python objects: lists, dicts, strings, numbers. The file must already be opened withopen. -
Handle
FileNotFoundErrorandjson.JSONDecodeErrorseparately. Different errors need different responses. A missing file might mean "use defaults." Invalid JSON might mean "file is corrupted." -
Use
tmp_pathfor test files. The pytesttmp_pathfixture gives you a clean temporary directory for each test, avoiding file pollution in your project. -
withandpytest.raisesuse the same mechanism. In Chapter 52,with pytest.raises(...)managed exception tracking. Here,with open(...)manages file handles. Both are context managers that guarantee cleanup.
Looking Ahead
You now know how to catch, raise, and handle exceptions in real file operations. In Lesson 5, you will combine all of these skills to write a manual validation function for SmartNotes data. You will check every field of a note dictionary with isinstance, validate string lengths, and verify numeric bounds. The result will be painfully verbose, setting up the motivation for Chapter 55's Pydantic solution.