To read files in Python you need one function, open(), and one habit: keep the call inside a with block. That pairing hands you an explicit file mode, a file object that closes itself when the block ends, and a handle you can walk through one line at a time.
Here is the entire pattern in one breath: with open(path, "r", encoding="utf-8") as handle: binds an open file object to the name handle, and every read you want goes in the indented block beneath it. When that block finishes, whether normally or because something blew up, Python closes the file for you. Everything else on this page is detail on top of that one shape.
What do the letters in a file mode actually mean?
The second argument to open() is a short mode string. The first character decides whether you can read, write, or both; an optional + character upgrades a mode so it does both at once without truncating the file.
| Mode | Meaning | If the file does not exist | Existing content |
|---|---|---|---|
r | read only | raises FileNotFoundError | left untouched |
w | write only | created empty | truncated, i.e. erased |
a | append only | created empty | kept, new data goes to the end |
x | exclusive create | created empty | raises FileExistsError |
r+ | read and write | raises FileNotFoundError | kept, cursor starts at position 0 |
The one that bites beginners is w. It does not mean "write and keep what was there" — it means "write, and if something was there, throw it away first". When you want to add to a file rather than replace it, that is the a mode. The x mode is the safe one for anything a machine generates, because it fails loudly instead of quietly overwriting a file you care about.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "app.log"
log.write_text("first run\n", encoding="utf-8")
with log.open("a", encoding="utf-8") as handle:
handle.write("second run\n")
print(log.read_text(encoding="utf-8"))
print("mode 'w' would have thrown the first line away")
Why does every open() call belong inside a with block?
An open file is a live operating-system handle, and the operating system has a hard cap on how many a single process may hold. Forget to close one and you eventually hit an OSError: [Errno 24] Too many open files. On Windows the handle also stays locked, so the user cannot delete or rename the file while your script is running.
Python's garbage collector will eventually close an orphaned file object, but "eventually" is not a plan. The with statement closes the file at a moment you choose: the end of the block. It does so even when the block raises, which means the cleanup happens on the error path too. The with statement is not special-cased for files at all — open() simply returns an object that implements the context manager protocol, which is why handle.closed flips to True the moment the block ends.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "notes.txt"
target.write_text("alpha\nbeta\n", encoding="utf-8")
with target.open(encoding="utf-8") as handle:
print("inside the block, closed is:", handle.closed)
print("first line:", handle.readline().strip())
print("after the block, closed is:", handle.closed)
target.unlink()
print("file deleted, so the handle really was released")
There is a subtle benefit here too. A with block keeps the handle's lifetime tied to the scope that uses it, so there is no way to accidentally use a closed file later in the function and get a confusing ValueError: I/O operation on closed file instead of a clear FileNotFoundError.
How do you read a file line by line instead of loading all of it?
Python offers three ways to pull text back out of a file, and they differ enormously in memory use. handle.read() returns the entire remaining contents as one string. handle.readlines() returns a list of strings, one per line. Iterating over the handle directly — for line in handle: — hands you one line at a time and never materialises the rest.
For a file of unknown size, iterate. For a small config file you intend to search as a whole, read() is convenient and often faster because it asks the operating system for one big block instead of many small ones. A useful middle ground is readline(), which returns the next single line and lets you walk a file forward one step at a time.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "notes.txt"
target.write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
with target.open(encoding="utf-8") as handle:
print("read() ->", repr(handle.read()))
with target.open(encoding="utf-8") as handle:
print("readlines() ->", [line.strip() for line in handle.readlines()])
with target.open(encoding="utf-8") as handle:
for number, line in enumerate(handle, start=1):
print(f"line {number}: {line.rstrip()}")
One detail that trips people up: line endings. read() returns the file's line separators exactly as they appear on disk, so a Windows-created file read on Linux shows \r\n inside the strings. readline() and readlines() behave the same way. If you want clean text, strip the trailing newline yourself with line.rstrip("\n") or line.strip().
What happens when the file is not there?
Opening a missing file for reading raises FileNotFoundError, which is a subclass of OSError. That inheritance is not a formality — it lets you write one handler for "something went wrong at the filesystem level" and a narrower handler for "the path is wrong" when you want to treat those two cases differently.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
missing = Path(tmp) / "does-not-exist.txt"
try:
missing.read_text(encoding="utf-8")
except FileNotFoundError:
print("no such file:", missing.name)
except OSError as exc:
print("other filesystem problem:", exc)
else:
print("this only runs when nothing went wrong")
Notice the order of the except clauses. Python checks them top to bottom and runs the first one that matches, so the specific FileNotFoundError handler must come before the broad OSError handler. Put them the other way round and the specific handler becomes dead code that never runs.
How do you control the encoding and move the cursor around?
If you omit encoding, Python uses whatever the machine's locale says, which differs between a developer's laptop, a CI runner and a Docker container. Pass encoding="utf-8" explicitly and your script reads the same bytes the same way everywhere. Python 3.15 changes the default to UTF-8 with a warning-free mode, but on Python 3.12 and 3.13 the explicit argument is still the habit worth forming.
Text-mode handles also let you move the cursor. handle.tell() reports the current position and handle.seek(offset) jumps to one, with whence choosing the reference point: the start of the file, the current position, or the end. In text mode tell() returns an opaque cookie rather than a plain byte count, which is why it is rarely a number you will compare against directly.
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "latin.txt"
target.write_bytes("caf\u00e9\n".encode("latin-1"))
with target.open(encoding="latin-1") as handle:
print("decoded as latin-1:", ascii(handle.readline().strip()))
print("cursor after one line:", handle.tell())
with target.open(encoding="utf-8") as handle:
try:
handle.read()
except UnicodeDecodeError:
print("utf-8 cannot decode those bytes")
The UnicodeDecodeError in that last example is worth internalising: it means the bytes in the file are not valid in the encoding you asked for. Real-world files are sometimes Latin-1 or UTF-16, and the fix is to open with the correct encoding rather than to catch the error and shrug.
Key takeaways
- Always pair
open()withwith. The file closes at the end of the block, including on the error path. - Pass the mode explicitly.
"r"reads,"w"writes and truncates,"a"appends,"x"refuses to overwrite. - Pass
encoding="utf-8"explicitly so behaviour does not drift with the machine's locale. - Iterate the handle for large files;
read()is fine for small ones you intend to treat as a whole. FileNotFoundErroris anOSError, so order yourexceptclauses from specific to general.
Try it yourself: build a line-numbered log reader
Write a script that asks for a filename on the command line, opens it for reading, and prints every line prefixed with its line number, skipping blank lines. Then extend it so that a missing file prints a friendly message instead of a traceback, and so that the path is opened with encoding="utf-8".
Finish with a scale test: create a file of 100,000 numbered lines using a loop, then time two versions of your reader — one that calls read() and one that iterates the handle. The iterating version holds one line in memory at a time, which is the habit you want for any file you did not write yourself.