Files Are Just Strings With a Path
Most programs eventually need to read or write a file — configs, user data, logs, CSV exports, small caches. Python keeps this simple with a single built-in function, open(), plus a with block to make sure things close cleanly.
Let's work through the common cases.
Reading a Whole File
The simplest read:
with open("notes.txt") as f:
contents = f.read()
print(contents)
open("notes.txt") opens the file for reading (the default). The with block means Python will close the file when you exit the block — even if an exception is raised. Once closed, you can't use f any more; the text lives in contents.
contents is a single string containing the entire file. Fine for small files; bad for a 2 GB log.
Reading Line by Line
For larger files, iterate instead of reading everything into memory:
with open("big.log") as f:
for line in f:
if "ERROR" in line:
print(line.strip())
Each iteration yields one line including the trailing newline — which is why .strip() shows up so often. If you want a list of lines, f.readlines() does that, but usually the direct iteration is what you want.
Writing a File
To write, pass the mode as a second argument:
with open("output.txt", "w") as f:
f.write("first line\n")
f.write("second line\n")
Two things to note:
"w"overwrites. Ifoutput.txtalready exists, its content is gone the momentopenruns.- You have to add newlines yourself.
f.write("hello")writes exactly those five characters — no trailing newline.
For appending instead of overwriting:
with open("output.txt", "a") as f:
f.write("another line\n")
You can also feed in multiple lines at once with writelines:
lines = ["a\n", "b\n", "c\n"]
with open("letters.txt", "w") as f:
f.writelines(lines)
Same rule: you supply the newlines.
print to a File
print has a file argument, which is an easy way to write without the manual newlines:
with open("log.txt", "w") as f:
print("started", file=f)
print("finished", file=f)
Each print writes its arguments plus a trailing newline. For casual output, this is often the nicest form.
Text vs Binary Mode
By default, open is in text mode. Python decodes bytes to strings (using UTF-8 by default on modern platforms) and handles newline translation.
For images, PDFs, compiled files, or anything that isn't text, open in binary mode by adding "b" to the mode:
with open("image.png", "rb") as f:
data = f.read()
print(len(data)) # number of bytes
with open("image_copy.png", "wb") as f:
f.write(data)
In binary mode, you get bytes objects instead of str, and Python doesn't touch line endings.
Specifying Encoding
For text files, be explicit about encoding. UTF-8 is almost always right in 2026:
with open("notes.txt", encoding="utf-8") as f:
contents = f.read()
If you're dealing with legacy files from a Windows-only shop, you might see cp1252 or latin-1 as well. Guessing wrong gives you garbled characters or a UnicodeDecodeError.
pathlib: The Modern Way to Handle Paths
The standard library's pathlib module gives you a more ergonomic API than raw strings:
from pathlib import Path
path = Path("notes.txt")
# Simple read and write.
contents = path.read_text(encoding="utf-8")
path.write_text("new contents\n", encoding="utf-8")
# Build paths without worrying about / vs \.
data_dir = Path("data")
log_path = data_dir / "today.log"
print(log_path)
print(log_path.exists())
print(log_path.suffix) # ".log"
print(log_path.stem) # "today"
print(log_path.parent) # "data"
Path.read_text() and write_text() are shortcuts for the with open(...) as f pattern when you just want all-at-once reads and writes. For line-by-line iteration, you still use open or path.open().
A Small End-to-End Example
Reading a list of items from a file, filtering them, and writing the result to another file:
from pathlib import Path
source = Path("items.txt")
destination = Path("filtered.txt")
items = source.read_text().splitlines()
kept = [item for item in items if item and not item.startswith("#")]
destination.write_text("\n".join(kept) + "\n")
print(f"Kept {len(kept)} items out of {len(items)}")
splitlines() is the line-oriented companion to join. It splits on newline characters without keeping them — handy when you're going to rejoin the pieces anyway.
When Things Go Wrong
Opening a file that doesn't exist raises FileNotFoundError. Trying to read a file you don't have permission for raises PermissionError. These are both subclasses of OSError, which in turn is a subclass of Exception — we'll talk about handling exceptions properly on the next page.
For now, a quick preview of what error handling looks like:
from pathlib import Path
path = Path("maybe.txt")
try:
contents = path.read_text()
except FileNotFoundError:
print("File doesn't exist — starting fresh.")
contents = ""
print(repr(contents))
Key Habits
- Always use
with open(...)instead of rawopen+ manual close. - Always pass
encoding=for text files; UTF-8 by default. - Iterate large files line-by-line; don't
.read()them whole. - For paths and common read/write patterns, use
pathlib— it'll save you a lot of string gymnastics.
Up next: JSON. Most real-world text files you read aren't plain lines — they're structured, and JSON is the most common shape. The same with open(...) habits carry over directly.
Frequently Asked Questions
How do I read a file in Python?
Use open() with the with statement, which closes the file automatically when you're done. with open('file.txt') as f: contents = f.read() reads the whole file into a string. For line-by-line, loop over the file object directly: for line in f:.
What's the difference between open modes 'r', 'w', and 'a'?
'r' is read (the default). 'w' is write — it creates the file or truncates it if it exists. 'a' is append — it adds to the end of the file without erasing what's there. Add 'b' for binary mode or '+' for read+write.
Why should I use with open instead of plain open?
with open instead of plain open?The with statement guarantees the file is closed even if an error happens partway through. Without with, you have to call .close() yourself and remember to do it in error paths. with is safer and less code.