Skip to content

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.

File I/O and exception handling are the two mechanisms that make programs robust. open() gives access to the filesystem; with ensures cleanup happens even on error. try/except catches specific exception types, letting you recover gracefully instead of crashing. Together they form the foundation of any script that runs unattended.

File operations in Python go through the OS's file descriptor abstraction. open() returns a file object whose type depends on the mode and buffering settings. Context managers guarantee resource cleanup via __enter__ and __exit__. Exception handling uses structured unwinding: when an exception propagates, Python walks the call stack looking for matching except clauses, executing finally blocks on the way out regardless.

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.

open(path, mode) returns a file object. The mode string controls access: "r" for reading, "w" for writing (truncating first), "a" for appending. Adding "b" gives binary mode. The default encoding for text mode is the system locale; specify encoding="utf-8" explicitly for portability.

open() calls the OS to allocate a file descriptor and returns a buffered I/O wrapper. Text mode wraps with TextIOWrapper, which handles encoding and universal newlines. Binary mode returns a BufferedReader or BufferedWriter. The encoding parameter is critical for portability: the default (locale.getpreferredencoding()) differs by platform. Always specify encoding="utf-8" for text files unless there is a specific reason not to.

python
f = open("data.txt", "r")    # "r" = read
content = f.read()
f.close()

The "r" is the mode:

ModeMeaning
"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 locked and can cause data corruption. The reliable way to handle this is the with statement.

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 is Python's context manager syntax. It calls __enter__ at the start and __exit__ at the end, even if an exception is raised. For files, __exit__ closes the file descriptor. This guarantees cleanup without requiring a try/finally wrapper around every file access.

with expr as name calls expr.__enter__() and binds the result to name. On exit (normal or exception), expr.__exit__(exc_type, exc_val, tb) is called. If __exit__ returns truthy, the exception is suppressed. File objects implement this protocol: __exit__ calls self.close(). Context managers can be stacked: with open(a) as f, open(b) as g: is idiomatic for working with multiple files.

python
with open("data.txt", "r") as f:
    content = f.read()

# f is closed here, guaranteed

What does with do?

with is Python's context manager syntax. It calls setup and teardown code for you; in this case, opening and reliably closing the file. You do not need to know how it works internally. Just use it with open().

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.

.read() loads the entire file into memory. .readline() reads one line including the newline character. .readlines() returns a list of all lines. Iterating the file object directly is the most memory-efficient pattern for large files: it reads one line at a time from the buffer without holding the full content.

.read() reads until EOF, returning the content as a string (or bytes in binary mode). .readline() reads until \n or EOF. Iterating the file object calls __iter__, which calls readline() repeatedly: O(1) memory, line-by-line processing. .readlines() is equivalent to list(file) but materialises all lines upfront. For very large files, the iterator pattern is preferred over .read().

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

python
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 newline

Iterating directly over the file object (for line in f) is the most efficient and idiomatic way to read a large file.

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().

.write(s) writes a string and returns the number of characters written. It does not add a newline. .writelines(iterable) writes each string from the iterable without adding separators. "w" truncates on open; "a" seeks to EOF. For portability, use "\n" or os.linesep rather than hardcoding platform-specific line endings.

.write() writes to the buffer; data may not reach disk until .flush() or .close() is called. "w" mode calls truncate(0) on open, destroying any previous content. "a" mode seeks to EOF before each write, making it safe for concurrent appenders (on most OSes). f.writelines() calls f.write() for each item: no extra memory allocation but also no separators added.

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

python
lines = ["Line one", "Line two", "Line three"]

with open("output.txt", "w") as f:
    f.write("\n".join(lines) + "\n")

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.

Exceptions are objects that inherit from BaseException. All user-facing exceptions inherit from Exception. When raised, Python unwinds the call stack looking for a matching except clause. The traceback shows the full call chain from the point of the exception back to the entry point.

Exception objects carry type, message, and a __traceback__ attribute pointing to the traceback object. raise creates a new exception; raise with no argument re-raises the current exception preserving the original traceback. Exception chaining (raise B from A) links two exceptions and is shown in the traceback. BaseException is the root; KeyboardInterrupt and SystemExit inherit from it directly, not from Exception, which is why a bare except Exception does not catch them.

Common exceptions you will encounter:

ExceptionWhen it happens
FileNotFoundErroropen() cannot find the file
ValueErrorFunction gets a value of the right type but wrong content, e.g. int("abc")
TypeErrorWrong type entirely, e.g. "hello" + 5
KeyErrorDictionary key does not exist
IndexErrorList index out of range
ZeroDivisionErrorDivision by zero
AttributeErrorObject does not have that attribute or method

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/except intercepts specific exception types. Specify the type to avoid silently swallowing unrelated errors. You can bind the exception to a name with as e to inspect the message. Multiple except clauses handle different exception types; Python matches the first compatible clause.

except ExceptionType as e matches if isinstance(raised_exception, ExceptionType) is true. This means catching Exception also catches all subclasses. Python matches the first except clause whose type matches; subsequent clauses are skipped. except (A, B) as e catches either type. A bare except: catches everything including KeyboardInterrupt and SystemExit, which almost never makes sense.

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

python
# 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")

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.

Multiple except clauses are evaluated top to bottom; the first match wins. Catching multiple types in a tuple catches any of them. Using as e binds the exception instance, giving access to the message, type, and traceback. Catching the most specific type first and more general types later avoids shadowing.

except (A, B) as e is syntactic sugar for a single clause that catches both. The as e binding is cleared after the except block exits (to prevent reference cycles with the traceback). For re-raising with additional context, use raise ValueError("context") from e, which sets __cause__ and shows both exceptions in the traceback.

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

python
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.

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.

else separates "normal path" code from the try body, making it clear that code in else only runs when no exception was raised. finally is the cleanup guarantee: it runs even if an exception was raised, caught, or re-raised. It even runs if return or break is encountered.

else runs if the try block completed without raising. finally runs unconditionally, including after return, break, continue, or an unhandled exception. If both finally and the calling code have return statements, finally's return takes precedence. finally with file handles or database connections is a secondary safety net when with is not available.

python
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 runs

finally is most useful for cleanup (closing connections, releasing locks) even when you are already using with for files.

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.

raise ExceptionType("message") creates and raises an exception. raise with no argument re-raises the current exception inside an except block. Raising exceptions from your functions makes their error conditions explicit, so callers can handle them specifically.

raise expr evaluates expr to get an exception instance and sets __traceback__. raise alone re-raises the current exception without modifying the traceback. raise B from A sets B.__cause__ = A (explicit chaining); raise B from None suppresses context display. After catching and logging, raise is the clean way to propagate while preserving the original traceback.

python
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

This makes your functions explicit about what they expect and signals problems clearly to callers.

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.

Custom exceptions create a hierarchy that callers can catch at the right level of specificity. Subclassing Exception is usually all you need. For exception families, create a base exception class and subclass it for specific error modes; callers can then catch the base type to handle all variants.

Custom exceptions should subclass Exception (not BaseException). Adding __init__ with domain-specific fields lets callers inspect the exception programmatically, not just read the message string. Exception hierarchies let callers choose their level of specificity: except PaymentError catches all payment-related errors; except InsufficientFundsError catches only that specific case.

python
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 -= amount
python
try:
    account.withdraw(1000)
except InsufficientFundsError as e:
    print(f"Transaction declined: {e}")

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 dict or list. json.dump() writes a Python dict or list to a file as JSON.

json.load() and json.dump() work with file objects. json.loads() and json.dumps() work with strings. The indent= parameter in dump/dumps makes the output human-readable. json.JSONDecodeError (a subclass of ValueError) is raised on invalid JSON.

json.load() is a streaming parser: it reads from the file object incrementally. json.dumps() returns a string; json.dump() writes to a file-like object supporting .write(). The default= parameter of dump/dumps handles types that are not JSON-serialisable; it receives the unserializable object and should return something serialisable. object_hook in load/loads intercepts each parsed object dict, enabling custom deserialisation.

Read JSON from a file:

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

python
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-readable

JSON to Python type mapping:

JSONPython
object {}dict
array []list
string ""str
numberint or float
true / falseTrue / False
nullNone

To convert between JSON strings and Python objects without touching a file:

python
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.

In practice

A save/load pattern for a simple game: write state to JSON, load it back on the next run, and fall back to defaults if no save file exists yet:

python
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)

Loading a config file and saving results, with specific exception handling for each failure mode:

python
import json

def load_config(path: str) -> dict:
    try:
        with open(path, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in {path}: {e}")

def save_results(results: list[dict], path: str) -> None:
    with open(path, "w") as f:
        json.dump(results, f, indent=2)
    print(f"Saved {len(results)} result(s) to {path}")

config  = load_config("experiment.json")
results = [{"epoch": 1, "loss": 0.82}, {"epoch": 2, "loss": 0.61}]
save_results(results, "results.json")

A structured log writer that appends timestamped entries to a file, with a top-level handler that catches and logs unexpected failures:

python
import json
from datetime import datetime

LOG_FILE = "run.log"

def log(level: str, message: str) -> None:
    ts    = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    entry = f"[{ts}] [{level.upper():7}] {message}\n"
    with open(LOG_FILE, "a") as f:
        f.write(entry)

def process(config_path: str) -> None:
    log("info", f"Starting job, config: {config_path}")
    try:
        with open(config_path) as f:
            config = json.load(f)
        log("info", f"Loaded config: {config}")
    except FileNotFoundError:
        log("error", f"Config not found: {config_path}")
        raise
    except json.JSONDecodeError as e:
        log("error", f"Bad JSON in config: {e}")
        raise

try:
    process("config.json")
except Exception as e:
    log("critical", f"Job failed: {e}")

Re-raising after logging preserves the original traceback for the caller. The top-level except Exception catches anything that slipped through, logs it as critical, and lets the process exit cleanly.