Skip to main content

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.

If you're new to programming

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.

If you know resource management from another language

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.

Understanding with

In 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:

ApproachLinesGuarantees CloseRisk of Forgetting
Manual f.close()3No (skipped on exception)High
try/finally4YesLow (but verbose)
with open(...)2YesNone (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 path with with open
  • Parses the JSON with json.load
  • Returns the result (expected to be a list of strings)
  • Catches FileNotFoundError and returns an empty list
  • Catches json.JSONDecodeError and returns an empty list

Write three tests using tmp_path:

  1. test_load_tags_valid: creates a file with ["python", "notes"], verifies the function returns ["python", "notes"]
  2. test_load_tags_missing: passes a non-existent path, verifies empty list
  3. test_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

Opening Claude Code

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

  1. with open guarantees the file is closed. Even if an exception occurs inside the block, the file handle is released. This replaces the verbose try/finally pattern.

  2. json.load(f) parses JSON from a file object. It returns Python objects: lists, dicts, strings, numbers. The file must already be opened with open.

  3. Handle FileNotFoundError and json.JSONDecodeError separately. Different errors need different responses. A missing file might mean "use defaults." Invalid JSON might mean "file is corrupted."

  4. Use tmp_path for test files. The pytest tmp_path fixture gives you a clean temporary directory for each test, avoiding file pollution in your project.

  5. with and pytest.raises use 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.