Reading and Writing Text Files in Python

Almost every real program eventually needs to persist data beyond a single run — logs, config files, exported reports, saved user data. python read write text files covers all of this, and the python open() function is the entry point for every one of these operations. This article covers file modes, the with statement, three distinct reading strategies, writing and appending, and the errors that trip up nearly everyone the first time they work with files.

Introduction: Why File I/O Matters

Once a Python program finishes running, everything stored purely in variables disappears — file I/O (input/output) is how a program persists data beyond that single run, or reads in data that already exists somewhere on disk.

open(): the entry point

Every file operation in Python starts with the built-in open() function, which returns a file object you can then read from or write to:

file = open("data.txt")
What this article covers

File modes (read, write, append, and more), three distinct strategies for reading file content depending on file size, writing and appending safely, and the specific errors — missing files, encoding mismatches, a subtle cursor-position gotcha — that catch nearly everyone the first time they work with files in Python.

Opening Files: Modes and the with Statement

The essential modes

open()'s second argument specifies the mode — what you intend to do with the file:

open("data.txt", "r")   # read (default if no mode is specified)
open("data.txt", "w")   # write — creates the file, or DESTRUCTIVELY overwrites an existing one
open("data.txt", "a")   # append — adds to the end, without disturbing existing content
open("data.txt", "x")   # create-only — raises FileExistsError if the file already exists
  • "r" (read) — the default mode if you don't specify one at all. Opens an existing file for reading; raises an error if the file doesn't exist.

  • "w" (write) — creates a new file, or immediately and silently erases the entire contents of an existing one with the same name, the moment it's opened.

  • "a" (append) — opens a file for writing, but adds new content to the end, leaving whatever's already there completely untouched.

  • "x" (create-only, "exclusive creation") — creates a new file, but raises FileExistsError if a file with that name already exists, rather than silently overwriting it. Genuinely useful whenever you specifically want to guarantee you're not accidentally clobbering existing data.

The with statement: the standard, safest approach
with open("data.txt", "r", encoding="utf-8") as f:
    c f.read()

print(contents)

with open(...) as f: is the standard, recommended way to work with files in Python, for a genuinely important reason: it automatically closes the file once the block exits — even if an error occurs inside the block — without you needing to manually call f.close(), or wrap everything in an explicit try/finally, exactly the pattern covered in the earlier try/except/finally article's discussion of context managers.

# The manual, error-prone equivalent — avoid this
f = open("data.txt", "r", encoding="utf-8")
try:
    c f.read()
finally:
    f.close()

Both versions behave identically, but with handles it more concisely and more safely — there's no risk of forgetting the close() call, or accidentally leaving a file handle open if an exception happens partway through reading.

Best practice: always specify encoding explicitly
with open("data.txt", "r", encoding="utf-8") as f:
    c f.read()

Without an explicit encoding argument, Python falls back to a platform-dependent default encoding — which can differ between Windows, macOS, and Linux. This is a genuine, common source of bugs: a file written and read correctly on your own machine can fail, or silently produce garbled text, on a different operating system, purely because the two systems assumed different default encodings. Explicitly specifying encoding="utf-8" (the standard, near-universal choice for text files today) removes this ambiguity entirely, and is worth treating as a non-negotiable habit for any file you open.

Reading Files: Three Approaches

.read(): the whole file as one string
with open("data.txt", "r", encoding="utf-8") as f:
    c f.read()

print(contents)

.read() loads the entire file's contents into memory at once, as a single string. This is simple and convenient for small files, but it risks genuinely high memory usage on large ones — reading a multi-gigabyte log file with .read() means holding that entire multi-gigabyte string in memory simultaneously, which can be slow or even crash your program depending on available memory.

.readline() and .readlines()
with open("data.txt", "r", encoding="utf-8") as f:
    first_line = f.readline()   # just one line
    sec f.readline()   # the next line

print(first_line, second_line)
with open("data.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()   # every line, as a list of strings

print(lines)   # ['First line\n', 'Second line\n', ...]

.readline() reads a single line per call, useful when you want fine-grained, one-at-a-time control. .readlines() reads every line at once, returning them as a list — genuinely useful when you need something like random access to a specific line by index (lines[5]), but it shares the same full-file memory concern as .read(), since it still loads everything into memory up front.

The memory-efficient pattern: iterating line by line

For large files — logs, big datasets, anything too large to comfortably fit in memory all at once — the correct pattern is iterating over the file object directly, rather than calling .read() or .readlines() at all:

with open("large_log.txt", "r", encoding="utf-8") as f:
    for line in f:
        if "ERROR" in line:
            print(line.strip())

This is exactly the generator-like, lazy pattern briefly demonstrated in the earlier generators article — iterating a file object reads one line at a time, processing and discarding each line before moving to the next, rather than holding the entire file's contents in memory simultaneously. For a genuinely huge file, this is the difference between a program that runs smoothly and one that runs out of memory entirely.

Writing and Appending to Files

"w" mode: create or overwrite
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Hello, World!\n")
    f.write("This is a new file.\n")

A clear warning worth repeating: opening a file in "w" mode immediately erases any existing content the moment the file is opened — even before you've written a single character. If output.txt already existed with important data in it, that data is gone the instant open("output.txt", "w") runs, regardless of what you go on to write afterward.

"a" mode: append without disturbing existing content
with open("output.txt", "a", encoding="utf-8") as f:
    f.write("This line gets added to the end.\n")

Unlike "w", "a" mode preserves everything already in the file, adding new content strictly after it — genuinely the safer default whenever you're adding to a file (like a log) that should accumulate content over time, rather than being replaced each time.

.write() vs. .writelines()
with open("output.txt", "w", encoding="utf-8") as f:
    f.write("Single line\n")   # writes exactly the string given, nothing more
lines = ["First line\n", "Second line\n", "Third line\n"]

with open("output.txt", "w", encoding="utf-8") as f:
    f.writelines(lines)   # writes each string in the list, one after another

A genuinely important detail worth emphasizing: .writelines() does not automatically add newline characters between items — if your strings don't already include \n at the end, the resulting file will have every "line" run together with no actual line breaks at all:

lines = ["First line", "Second line"]   # missing \n!

with open("output.txt", "w", encoding="utf-8") as f:
    f.writelines(lines)

# The resulting file contains: "First lineSecond line" — all on one line!

You have to include \n explicitly in each string yourself — .writelines() is purely a convenience for writing several strings in one call; it doesn't do any formatting or line-joining on your behalf.

Common Errors and Best Practices

Handling a missing file gracefully

Trying to open a file that doesn't exist in read mode raises FileNotFoundError:

with open("nonexistent.txt", "r", encoding="utf-8") as f:
    c f.read()
# FileNotFoundError: [Errno 2] No such file or directory: 'nonexistent.txt'

Rather than letting this crash your program outright, wrap it in a try/except, exactly as covered in the earlier exception-handling articles:

try:
    with open("nonexistent.txt", "r", encoding="utf-8") as f:
        c f.read()
except FileNotFoundError:
    print("The file doesn't exist yet.")
    c ""
Avoiding UnicodeDecodeError

If a file was written with a different encoding than the one you're reading it with, you can hit a UnicodeDecodeError:

with open("data.txt", "r", encoding="ascii") as f:
    c f.read()
# UnicodeDecodeError: 'ascii' codec can't decode byte 0xe2 in position 10

This is precisely why explicitly specifying encoding="utf-8" — covered in Section 2 — matters so much: it removes the platform-dependent guessing that causes exactly this kind of mismatch, and UTF-8 correctly handles the overwhelming majority of real-world text, including non-English characters, emoji, and other symbols that simpler encodings like ASCII genuinely can't represent at all.

The cursor-position gotcha

This is a genuinely common source of confusion the first time someone hits it: a file object keeps track of its current read position internally, and reading a file a second time in the same session returns nothing at all — because the read pointer is already sitting at the very end of the file from the first read.

with open("data.txt", "r", encoding="utf-8") as f:
    first_read = f.read()
    sec f.read()

print(first_read)     # the full file contents
print(second_read)    # '' — empty! the cursor was already at the end

The fix is either explicitly resetting the cursor back to the beginning with f.seek(0), or simply reopening the file:

with open("data.txt", "r", encoding="utf-8") as f:
    first_read = f.read()
    f.seek(0)   # reset the cursor back to the start
    sec f.read()

print(second_read)   # the full file contents again, correctly
Binary mode, briefly

For non-text files — images, PDFs, any binary data — use "rb"/"wb" (read/write binary) instead of the plain text modes:

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

Binary mode reads and writes raw bytes objects rather than decoded text strings, and it doesn't accept an encoding argument at all, since there's no text decoding involved — a genuinely different mode for genuinely different kinds of files, worth knowing exists even if this article focuses on text files specifically.

A nod to pathlib

Modern Python code increasingly favors the pathlib module over plain string-based file paths — it offers a more object-oriented, cross-platform-safe way to work with file paths and even includes convenient shortcuts for simple reads and writes:

from pathlib import Path

c Path("data.txt").read_text(encoding="utf-8")
Path("output.txt").write_text("Hello!\n", encoding="utf-8")

pathlib is genuinely worth exploring further once you're comfortable with the fundamentals covered in this article — it's a large enough topic to deserve its own dedicated treatment later in this series, but it's worth knowing it exists as the more modern alternative to manually joining path strings together.