Reading and Writing Files — open(), read(), write()

File input and output is a fundamental skill — even web applications that primarily work with databases need to read configuration files, process uploaded documents, write log files, serve static assets, and handle backup exports. Python’s file handling is built around the open() function and the with statement, which guarantees the file is properly closed even if an exception occurs during processing. In FastAPI, file handling appears in upload endpoints, CSV import features, configuration loading, and anywhere you need to persist data to disk between application restarts.

Opening and Reading Files

from pathlib import Path

# ── Basic open() ──────────────────────────────────────────────────────────────
# open(path, mode, encoding)
# Modes: 'r' read (default), 'w' write, 'a' append
#        'rb' read binary, 'wb' write binary
#        'r+' read+write, 'x' exclusive create (fails if exists)

# Always use 'with' — automatically closes the file
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()        # entire file as one string
    print(content)

# Read line by line (memory-efficient for large files)
with open("data.txt", "r", encoding="utf-8") as f:
    for line in f:            # iterates one line at a time
        print(line.strip())   # strip() removes trailing \n

# Read all lines into a list
with open("data.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()     # ["line1\n", "line2\n", ...]
    lines = [l.strip() for l in lines]   # clean version

# Read a fixed number of characters
with open("data.txt", "r", encoding="utf-8") as f:
    first_100 = f.read(100)   # read exactly 100 characters
    rest       = f.read()     # read the remainder

# pathlib shortcut (Python 3.5+)
content = Path("data.txt").read_text(encoding="utf-8")
lines   = content.splitlines()
Note: Always specify encoding="utf-8" explicitly when opening text files. Without it, Python uses the platform’s default encoding — which is UTF-8 on most Linux/macOS systems but often a legacy encoding on Windows. Omitting the encoding parameter is a common source of UnicodeDecodeError crashes when your application moves between development (macOS) and production (Linux) environments, or when processing files uploaded by Windows users.
Tip: For large files (log files, CSV exports, data imports), always read line by line with a for loop rather than f.read() or f.readlines(). A for line in f: loop reads one line at a time from disk — the whole file is never in memory at once. This matters in FastAPI upload endpoints where a user might upload a 500MB CSV file that would exhaust your server’s memory if loaded all at once.
Warning: Never open a file without the with statement (or an explicit f.close()) in production code. If an exception occurs between f = open(...) and f.close(), the file is never closed — the file descriptor leaks. On operating systems, file descriptor leaks accumulate until the process hits the OS limit and can no longer open any files. The with statement’s __exit__ method calls close() even if an exception is raised inside the block.

Writing and Appending Files

# Write — creates file if not exists, OVERWRITES if exists
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("First line\n")
    f.write("Second line\n")
    f.writelines(["Third\n", "Fourth\n"])   # write multiple lines

# pathlib shortcut
Path("output.txt").write_text("content here", encoding="utf-8")

# Append — adds to end of file, creates if not exists
with open("log.txt", "a", encoding="utf-8") as f:
    f.write("2025-08-06 14:30:00 INFO Server started\n")

# Exclusive creation — fails if file already exists (safe)
try:
    with open("new_file.txt", "x", encoding="utf-8") as f:
        f.write("This file did not exist before")
except FileExistsError:
    print("File already exists — not overwritten")

# Write binary (images, PDFs, any non-text)
with open("image.png", "wb") as f:
    f.write(image_bytes)   # bytes object

# Read binary
with open("image.png", "rb") as f:
    data = f.read()   # returns bytes, not str

File Positions and Seeking

with open("data.txt", "r+", encoding="utf-8") as f:
    # tell() — current position in bytes from start
    print(f.tell())   # 0 — at start

    first_line = f.readline()
    print(f.tell())   # position after first line

    # seek() — move to a position
    f.seek(0)         # back to the start
    f.seek(0, 2)      # seek to end (0 bytes from end, whence=2)

    # Read again from the beginning
    content = f.read()

# Check file size
import os
size_bytes = os.path.getsize("data.txt")
size_kb    = size_bytes / 1024

Working with File Paths Safely

from pathlib import Path

upload_dir = Path("uploads")
upload_dir.mkdir(parents=True, exist_ok=True)

def save_upload(filename: str, content: bytes) -> Path:
    """Save uploaded file content and return the path."""
    # Sanitise filename — prevent directory traversal attacks
    safe_name = Path(filename).name   # strips any directory components
    dest      = upload_dir / safe_name

    dest.write_bytes(content)
    return dest

def read_config(config_path: str) -> dict:
    import json
    path = Path(config_path)
    if not path.exists():
        raise FileNotFoundError(f"Config not found: {config_path}")
    if not path.is_file():
        raise ValueError(f"Not a file: {config_path}")
    return json.loads(path.read_text(encoding="utf-8"))

# List uploads directory
def list_uploads() -> list:
    return [f.name for f in upload_dir.iterdir() if f.is_file()]

Common Mistakes

Mistake 1 — Opening a file without the with statement

❌ Wrong — file never closed if exception occurs:

f = open("data.txt")
content = f.read()
# If an exception happens here — f.close() is never called!
f.close()

✅ Correct — with statement guarantees close:

with open("data.txt", encoding="utf-8") as f:
    content = f.read()   # ✓ file always closed after block

Mistake 2 — Loading entire large file into memory

❌ Wrong — 500MB CSV loaded all at once:

with open("big.csv") as f:
    all_lines = f.readlines()   # 500MB in RAM!

✅ Correct — process line by line:

with open("big.csv", encoding="utf-8") as f:
    for line in f:   # one line at a time ✓
        process(line.strip())

Mistake 3 — Writing mode overwrites without warning

❌ Wrong — accidentally destroying existing file:

with open("important.txt", "w") as f:   # silently destroys existing content!
    f.write("new content")

✅ Correct — use “x” mode to prevent overwrite, or check existence first:

if not Path("important.txt").exists():
    with open("important.txt", "w", encoding="utf-8") as f:
        f.write("new content")   # ✓ only if file doesn't exist

Quick Reference

Mode Meaning Creates? Overwrites?
"r" Read text No (error) N/A
"w" Write text Yes Yes
"a" Append text Yes No
"x" Exclusive write Yes Error if exists
"rb" Read binary No (error) N/A
"wb" Write binary Yes Yes
"r+" Read+write No (error) Partial

🧠 Test Yourself

You open a file with open("log.txt", "w") and the file already contains 100 lines of important log data. What happens to those 100 lines?