Why File Handling Matters — Programs That Remember
A file is that notebook on your desk. You write things into it, close it, and tomorrow — or ten years from now — you open it and pick up exactly where you left off. File handling is how programs turn short-term memory into long-term memory.
In Python, a text file is a sequence of characters stored on disk. Files come in two flavours — text (readable characters like source code, CSVs, logs, JSON, HTML) and binary (images, executables, PDFs). This tutorial focuses entirely on text files, which cover 90% of everyday scripting needs.
Reading and writing files always follows the same three-step ritual: open
the file, read or write the data, then close the file.
Miss the last step and you get corrupted data, locked files, and mysterious empty outputs.
Python's with statement handles this ritual for you — and you should use it
almost every single time.
The open() Function — Your Gateway to Every File
Every file operation starts with the built-in open() function. It takes a path
and a mode, and hands back a file object that you can read from or write to.
# Basic syntax
f = open("notes.txt", "r", encoding="utf-8")
content = f.read()
f.close()
print(content)
open()pathlib.Path.
\r\n to \n automatically.
encoding="utf-8"
Without it, Python uses the operating system's default — cp1252 on Windows,
utf-8 on Linux/macOS. Your script works on your machine, fails on a colleague's,
and prints garbled text with any non-ASCII character. Making it explicit costs nine
keystrokes and prevents a whole category of bugs.
File Modes — Read, Write, Append (and the Ones That Bite)
The second argument to open() is the mode. Pick the wrong one
and you either can't do what you want — or worse, silently destroy an existing file.
| Mode | Meaning | File must exist? | Wipes existing content? | Position starts at |
|---|---|---|---|---|
"r" | Read only | Yes | No | Beginning |
"w" | Write only | No — creates it | Yes — truncates! | Beginning |
"a" | Append only | No — creates it | No | End |
"x" | Exclusive create | No — must NOT exist | No | Beginning |
"r+" | Read + write | Yes | No | Beginning |
"w+" | Write + read | No — creates it | Yes — truncates! | Beginning |
"a+" | Append + read | No — creates it | No | End |
"w"
Opening any existing file in mode "w" — even for a single character —
immediately empties the entire file. There is no confirmation, no
undo, no warning. If you meant to add to a file, you wanted "a", not
"w". Making this mistake on config.json or a database dump
has ruined many a Friday afternoon.
Text Mode vs Binary Mode
Add "b" to any mode (like "rb", "wb") and you're
in binary mode — you read and write raw bytes instead of decoded strings. Use binary for
images, PDFs, and archives. For everything human-readable, stay in text mode.
The with Statement — The Only Right Way to Open Files
Manually calling close() is fragile. If an exception happens between
open and close, the file stays open until the garbage collector
gets around to it — leaking file handles, locking files on Windows, and losing buffered
writes. The with statement (a context manager) guarantees
the file gets closed the moment the block ends, even if an error is raised.
| Behaviour | Problem |
|---|---|
close() may be skipped | If an exception fires, file stays open |
| Manual bookkeeping | You must remember to close every branch |
| Buffered writes lost | Data may never hit disk |
| File locks linger | Other programs can't open it (Windows) |
with Way| Behaviour | Benefit |
|---|---|
| Auto-close on block exit | Guaranteed cleanup, always |
| Exception-safe | Even if code inside raises, file closes |
| Buffers flushed | Writes actually reach disk |
| File handle released | Other programs can read it immediately |
# The old, error-prone pattern - avoid
f = open("notes.txt", "r", encoding="utf-8")
data = f.read()
f.close() # skipped if read() raises
# The correct pattern - use this everywhere
with open("notes.txt", "r", encoding="utf-8") as f:
data = f.read()
# f is guaranteed closed here, even if read() raised
print(data)
If you write open( without with in front of it, stop and
rewrite the line. It's that simple. Every mature Python codebase uses with
for file I/O — no exceptions.
Reading Text Files — Four Techniques
Python gives you four ways to pull text out of a file. Which one to pick depends entirely on file size and how you want to process the content.
read() — the whole thingreadline() — one line at a time\n. Returns an
empty string at end-of-file. Useful when you need explicit control over the read position.
readlines() — list of linesread() — the whole file lives in RAM. Fine for small files, disastrous for
large ones.
A file object is itself iterable. for line in f: reads one line at a time
and only holds that single line in memory. This handles files of any size —
100 bytes or 100 GB — with the same tiny memory footprint. Use this unless
you have a specific reason not to.
Example — All Four Techniques Side by Side
# Assume notes.txt contains:
# Buy milk
# Finish tutorial
# Call mom
# 1) read() - everything as one string
with open("notes.txt", encoding="utf-8") as f:
text = f.read()
print(repr(text))
# 2) readline() - one line, keeps \n
with open("notes.txt", encoding="utf-8") as f:
first = f.readline()
print(repr(first))
# 3) readlines() - list of lines
with open("notes.txt", encoding="utf-8") as f:
lines = f.readlines()
print(lines)
# 4) Iterate the file - best for large files
with open("notes.txt", encoding="utf-8") as f:
for i, line in enumerate(f, 1):
print(f"{i}: {line.rstrip()}")
.rstrip() When Iterating
Every line read from a text file keeps its trailing newline. If you print
it as-is, you get double-spaced output because print adds its own
\n. Call line.rstrip() — or the more targeted
line.rstrip("\n") — to remove it before processing.
Writing Text Files — write() and writelines()
Writing is symmetric to reading. Two methods handle it: write() takes one string,
writelines() takes an iterable of strings. Neither adds a newline for you —
that's your job.
# write() - one string at a time. Add \n yourself.
with open("todo.txt", "w", encoding="utf-8") as f:
f.write("Buy milk\n")
f.write("Finish tutorial\n")
f.write("Call mom\n")
# writelines() - takes an iterable. Still no auto-newlines!
tasks = ["Buy milk\n", "Finish tutorial\n", "Call mom\n"]
with open("todo2.txt", "w", encoding="utf-8") as f:
f.writelines(tasks)
# Idiomatic pattern - join then write once
with open("todo3.txt", "w", encoding="utf-8") as f:
f.write("\n".join(["Buy milk", "Finish tutorial", "Call mom"]))
writelines() Naming Trap
Despite the name, writelines() does not add line separators.
If your list contains ["a", "b", "c"], the file ends up containing
"abc", not three lines. You must include the \n yourself in
every element, or use "\n".join(...) first. It's arguably Python's
worst-named standard-library method.
Reminder — Mode "w" Wipes First
# Before running: todo.txt contains 100 important lines.
with open("todo.txt", "w", encoding="utf-8") as f:
f.write("just this one line\n")
# After running: todo.txt contains ONE line. The other 100 are gone forever.
Appending — Mode "a"
To add to a file without destroying what's already in it, use append mode. The file pointer starts at the end and every write extends the file. This is the mode you want for logs, audit trails, and any incremental data collection.
import datetime
def log_event(message):
now = datetime.datetime.now().isoformat(timespec="seconds")
with open("app.log", "a", encoding="utf-8") as f:
f.write(f"[{now}] {message}\n")
log_event("server started")
log_event("user aarav signed in")
log_event("processed 42 requests")
# Read it back
with open("app.log", encoding="utf-8") as f:
print(f.read())
"a" Creates the File If Missing
You don't have to check for the file's existence before appending. If app.log
doesn't exist yet, mode "a" creates it. If it exists, new lines get added to
the end. This makes append the perfect mode for scripts that run repeatedly and just want
to keep logging.
Encoding — The One Argument You Should Never Skip
UnicodeDecodeError. She hadn't specified
encoding, so Python used the OS default: cp1252 in Frankfurt, utf-8
on her Mac. Same code, same file, different result.Fixing it was one word:
encoding="utf-8". Not adding it in the first place
cost her a Sunday.
A text file on disk is really just bytes. An encoding is the rule that turns those bytes into characters and vice versa. UTF-8 is the modern universal choice — it handles every language and script, and it's the default on nearly every platform except older Windows.
# Always the right choice for text files
with open("names.txt", encoding="utf-8") as f:
for line in f:
print(line.rstrip())
# Writing with explicit encoding
names = ["Sofía", "Zoë", "Björn", "王小明"]
with open("names.txt", "w", encoding="utf-8") as f:
f.write("\n".join(names))
When reading data from arbitrary sources, occasional bad bytes are inevitable. The
errors= argument controls what happens on decode failure:
"strict" (default — raise), "replace" (insert
U+FFFD), or "ignore" (drop bytes silently). Use
"replace" when you'd rather see partial data than a crash.
File Paths — Working with pathlib
Hard-coding paths as strings works — until it doesn't. Slashes vs backslashes, joining
segments, checking existence, extracting extensions — the standard pathlib
module handles it all cleanly and portably.
from pathlib import Path
# Build paths without worrying about \ vs /
data_dir = Path("data")
notes = data_dir / "notes.txt"
print(notes) # data/notes.txt (or data\notes.txt on Windows)
# Handy inspection methods
print("exists? ", notes.exists())
print("is file? ", notes.is_file())
print("suffix: ", notes.suffix)
print("stem: ", notes.stem)
print("parent: ", notes.parent)
# pathlib plays nicely with open()
with open(notes, encoding="utf-8") as f:
text = f.read()
# Even simpler - Path has shortcuts
text = notes.read_text(encoding="utf-8")
notes.write_text("a new note", encoding="utf-8")
read_text / write_text
For small files where you want the whole content in one shot,
Path.read_text() and Path.write_text() are shorter than
with open(...) as f:. Under the hood they use the same machinery and still
close the file properly. Skip them for large files — you still want line-by-line iteration
there.
Error Handling — The Errors You Will Hit
File I/O has more failure modes than almost any other operation: the file doesn't exist, you don't have permission, the disk is full, the encoding is wrong, someone else has it locked. Handle the specific exceptions you care about — don't blanket-catch everything.
| Exception | When it fires | Typical fix |
|---|---|---|
FileNotFoundError | Reading a file that doesn't exist | Check first, or fall back to a default |
PermissionError | OS denies read or write access | Fix permissions, or run with rights |
FileExistsError | Mode "x" and file already exists | Pick a new name, or use mode "w" |
IsADirectoryError | You passed a directory to open | Fix the path |
UnicodeDecodeError | File isn't valid for the given encoding | Set errors="replace" or right encoding |
OSError | Disk full, hardware error, path too long | Handle at the outermost layer |
def load_config(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
print(f"Config not found at {path}, using defaults")
return ""
except PermissionError:
print(f"No permission to read {path}")
raise
except UnicodeDecodeError as e:
print(f"Encoding problem in {path}: {e}")
raise
config = load_config("settings.ini")
Exception
except Exception: or bare except: swallows every error —
including bugs in your own code, keyboard interrupts, and unrelated failures. Catch
only the specific errors you know how to handle, and let the rest propagate up so you
can see them.
Common Pitfalls — Bugs Everyone Writes Once
f.read() once consumes the file. A second f.read()
returns "" because the pointer is at end-of-file. Call f.seek(0)
to rewind, or just open again.
f.write("a"); f.write("b") produces ab, not a\nb.
Neither write() nor writelines() adds separators. Always
include \n yourself.
"w" when you meant "a""w" truncates on open. If you meant to add to a file, you wanted
"a". This mistake has erased more logs than any hard disk failure.
encoding=encoding="utf-8". There is no downside.
read()read() becomes a 10 GB Python string.
Your process gets OOM-killed. Iterate line by line instead.
withopen and close leak file handles. Buffered
writes may never reach disk. Always use with open(...) as f:.
A Complete Real-World Example — A Word-Frequency Counter
Let's put every technique together. This script reads a text file, counts how often each
word appears (case-insensitive, ignoring punctuation), writes the top 10 to a report file,
and prints them. Notice how it uses with, explicit encoding, line-by-line
iteration, and targeted error handling.
import string
from collections import Counter
from pathlib import Path
def count_words(source_path, report_path, top_n=10):
counter = Counter()
translator = str.maketrans("", "", string.punctuation)
# READ - line by line, so file size does not matter
try:
with open(source_path, encoding="utf-8") as f:
for line in f:
cleaned = line.lower().translate(translator)
counter.update(cleaned.split())
except FileNotFoundError:
print(f"Source file not found: {source_path}")
return
top = counter.most_common(top_n)
# WRITE - overwrite the report file each run
with open(report_path, "w", encoding="utf-8") as f:
f.write(f"Top {top_n} words in {source_path}\n")
f.write("=" * 40 + "\n")
for rank, (word, count) in enumerate(top, 1):
f.write(f"{rank:2d}. {word:15s} {count}\n")
# APPEND - log this run to an audit file
with open("runs.log", "a", encoding="utf-8") as f:
total = sum(counter.values())
f.write(f"Processed {source_path}: {total} words, {len(counter)} unique\n")
# Show summary to caller
print(f"Report written to {report_path}")
for rank, (word, count) in enumerate(top, 1):
print(f" {rank:2d}. {word:15s} {count}")
count_words("article.txt", "report.txt", top_n=10)
Three separate with blocks (read, write, append). Explicit
encoding="utf-8" everywhere. Line-by-line iteration so a 5 GB novel
would work the same as a 5 KB blog post. Targeted FileNotFoundError
handling — no blanket except. And the append log makes the tool easy to audit later.
This is what production Python looks like.
Golden Rules
with open(...) as f: Never call
open without it. This one habit prevents leaked handles, lost writes,
and locked files across every OS.
encoding="utf-8" for text files. It costs
nine keystrokes and saves you from platform-specific bugs that only appear once your
code hits a Windows server or a file with an accent in it.
"r" for read, "a" for
append, "w" for a brand-new file. Never use "w" on data you
cannot afford to lose — it truncates instantly, no warning.
for line in f: uses constant memory regardless of file size.
f.read() and f.readlines() both load everything into RAM —
fine for config, terrible for logs.
write() nor
writelines() adds them. Forgetting this produces one giant concatenated
line that looks nothing like what you wrote.
\n. Use line.rstrip() or
line.rstrip("\n") before comparing, splitting, or storing.
Exception.
FileNotFoundError, PermissionError, UnicodeDecodeError
each mean something different and each deserves its own fallback. Blanket-catching hides
bugs you'd want to know about.
pathlib.Path over string paths. It handles slashes
portably, gives you exists(), suffix, and parent
for free, and offers read_text() / write_text() shortcuts
for small files.