BASIC Python (Beginner Level) 📂 Basic Python Programming · 14 of 15 32 min read

Python File Handling

Master reading and writing text files in Python: how open() works, every file mode (r, w, a, x, r+, w+, a+) and what each one does to your data, why with is the only safe way to open files, all four reading techniques (read, readline, readlines, iteration), writing and appending, why encoding="utf-8" is non-negotiable, pathlib basics, and the specific exceptions you must handle in real code.

Section 01

Why File Handling Matters — Programs That Remember

The Notebook on Your Desk
Imagine every day you show up at your desk with total amnesia. You've forgotten yesterday's notes, meeting outcomes, phone numbers — everything. That is exactly how a program behaves without files: it runs, computes brilliantly for a few seconds, then dies and takes all its memory with it.

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.

📚
The Core Insight

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.


Section 02

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)
🛠️ Anatomy of open()
path
The file's location — a relative name like notes.txt or absolute like /var/log/app.log. Can be a string or a pathlib.Path.
mode
What you want to do — read, write, or append. Full table in Section 03. Defaults to "r" (text-read).
encoding
How characters are decoded from bytes. Almost always use "utf-8". Defaulting to the platform encoding is a bug waiting to happen.
newline
How line endings are translated. Leave it alone for text files — Python normalises \r\n to \n automatically.
⚠️
Always Specify 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.


Section 03

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 onlyYesNoBeginning
"w"Write onlyNo — creates itYes — truncates!Beginning
"a"Append onlyNo — creates itNoEnd
"x"Exclusive createNo — must NOT existNoBeginning
"r+"Read + writeYesNoBeginning
"w+"Write + readNo — creates itYes — truncates!Beginning
"a+"Append + readNo — creates itNoEnd
🚩
The Silent Destroyer — Mode "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.


Section 04

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.

🚫 The Old, Fragile Way
BehaviourProblem
close() may be skippedIf an exception fires, file stays open
Manual bookkeepingYou must remember to close every branch
Buffered writes lostData may never hit disk
File locks lingerOther programs can't open it (Windows)
✅ The Modern with Way
BehaviourBenefit
Auto-close on block exitGuaranteed cleanup, always
Exception-safeEven if code inside raises, file closes
Buffers flushedWrites actually reach disk
File handle releasedOther 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)
🔑
One Rule to Live By

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.


Section 05

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.

📄
1. read() — the whole thing
returns str
Gulps the entire file into one string. Simple. Great for small config files. Dangerous for large files — a 4 GB log file becomes a 4 GB string in memory.
📋
2. readline() — one line at a time
returns str per call
Reads exactly one line each call, including the trailing \n. Returns an empty string at end-of-file. Useful when you need explicit control over the read position.
📜
3. readlines() — list of lines
returns list[str]
Reads all lines into a list. Convenient, but has the same memory problem as read() — the whole file lives in RAM. Fine for small files, disastrous for large ones.
🏆
4. Iterating the File Object — The Right Default

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()}")
OUTPUT
'Buy milk\nFinish tutorial\nCall mom\n' 'Buy milk\n' ['Buy milk\n', 'Finish tutorial\n', 'Call mom\n'] 1: Buy milk 2: Finish tutorial 3: Call mom
📈
Why .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.


Section 06

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"]))
🚩
The 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.

Section 07

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())
OUTPUT
[2026-07-10T14:23:07] server started [2026-07-10T14:23:07] user aarav signed in [2026-07-10T14:23:07] processed 42 requests
💡
Mode "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.


Section 08

Encoding — The One Argument You Should Never Skip

The Mystery of the Broken Accents
A developer in Bangalore writes a script that reads a CSV of customer names — Sofía, Zoë, Björn, 王小明. It works perfectly. She ships it to a Windows server in the Frankfurt data centre. Suddenly the log fills with 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))
🌐
Handling Unknown or Messy Encodings

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.


Section 09

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")
When To Use 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.


Section 10

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.

ExceptionWhen it firesTypical fix
FileNotFoundErrorReading a file that doesn't existCheck first, or fall back to a default
PermissionErrorOS denies read or write accessFix permissions, or run with rights
FileExistsErrorMode "x" and file already existsPick a new name, or use mode "w"
IsADirectoryErrorYou passed a directory to openFix the path
UnicodeDecodeErrorFile isn't valid for the given encodingSet errors="replace" or right encoding
OSErrorDisk full, hardware error, path too longHandle 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")
⚠️
Don't Blanket-Catch 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.


Section 11

Common Pitfalls — Bugs Everyone Writes Once

💩
Reading twice from same handle
returns empty second time
Calling 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.
💩
Forgetting the newline
writes concatenate
f.write("a"); f.write("b") produces ab, not a\nb. Neither write() nor writelines() adds separators. Always include \n yourself.
💩
Using "w" when you meant "a"
data loss
Mode "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.
💩
Skipping encoding=
platform-dependent bugs
Works on your Mac, fails on the Windows server, garbles accents in production. Always say encoding="utf-8". There is no downside.
💩
Reading a giant file with read()
out-of-memory
A 10 GB log file called into read() becomes a 10 GB Python string. Your process gets OOM-killed. Iterate line by line instead.
💩
Opening without with
leaked handles
Exceptions between open and close leak file handles. Buffered writes may never reach disk. Always use with open(...) as f:.

Section 12

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)
OUTPUT
Report written to report.txt 1. the 184 2. of 97 3. to 84 4. and 71 5. a 63 6. in 52 7. is 41 8. that 33 9. for 29 10. it 28
🏆
Every Best Practice Is In There

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.


Section 13

Golden Rules

📁 File Handling — Non-Negotiable Rules
1
Always use with open(...) as f: Never call open without it. This one habit prevents leaked handles, lost writes, and locked files across every OS.
2
Always pass 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.
3
Know your modes: "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.
4
Iterate the file object for anything non-tiny. 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.
5
Add your own newlines. Neither write() nor writelines() adds them. Forgetting this produces one giant concatenated line that looks nothing like what you wrote.
6
Strip trailing newlines when processing lines. Every line from a text file keeps its \n. Use line.rstrip() or line.rstrip("\n") before comparing, splitting, or storing.
7
Catch specific exceptions, not Exception. FileNotFoundError, PermissionError, UnicodeDecodeError each mean something different and each deserves its own fallback. Blanket-catching hides bugs you'd want to know about.
8
Prefer 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.