datarekha

File I/O

open() with the right mode, the with-statement that closes files for you, and streaming big files without melting your laptop.

8 min read Beginner Python Lesson 20 of 41

What you'll learn

  • The modes for open() — r, w, a, x, b, and what + does
  • Why every open() belongs in a with-statement
  • Streaming line-by-line versus reading a whole file into memory
  • Why you should always pass encoding='utf-8' explicitly

Before you start

open() is the one function for reading and writing files in Python, and its defaults are almost always wrong for code you intend to ship — wrong encoding, the wrong mode, the wrong assumption about how big the file is. The good news is that there are only a handful of knobs, and once you set them deliberately you stop chasing a whole category of “weird characters” and “the file disappeared” bugs. Let us turn each knob on purpose.

The modes

The second argument to open(path, mode, encoding=...) is a short string saying what you intend to do with the file:

'r'   read (the default)        — fails if the file does not exist
'w'   write, TRUNCATING first   — creates the file if missing
'a'   append                    — creates the file if missing
'x'   exclusive create          — fails if the file already exists
'b'   binary (combine: 'rb', 'wb')
'+'   read AND write            — rarely what you actually want

The single most expensive mistake hides in that list: opening with 'w' when you meant 'a', which silently empties an existing file before you write a byte. The grid below is worth burning into memory, because the dangerous cell — 'w' on a file that already exists — looks exactly as innocent as the others:

file…‘r''w''a''x’existsreadTRUNCATEappenderrormissingerrorcreatecreatecreate

Always use with

A file is an operating-system resource, and forgetting to close it leaks a file handle — let that happen in a loop and your process eventually dies with OSError: too many open files. The with statement is Python’s context-manager syntax, and it removes the chance of forgetting entirely: when the indented block ends — normally or by exception — it calls the file’s close() for you.

# Don't do this — the handle leaks if anything throws between open and close.
f = open("/tmp/notes.txt", "w", encoding="utf-8")
f.write("hello\n")
f.close()

# Do this — close() runs automatically on the way out, success OR exception.
with open("/tmp/notes.txt", "w", encoding="utf-8") as f:
    f.write("hello\n")
    f.write("world\n")

# Read it back.
with open("/tmp/notes.txt", "r", encoding="utf-8") as f:
    contents = f.read()
print(contents)
hello
world

For file I/O, with is not a style preference — it is the correct shape, and anything else is a bug waiting to ship.

Specify the encoding. Always.

Left to itself, open() uses the operating system’s default text encoding — utf-8 on Linux and macOS, but historically cp1252 on Windows. That is how code which “works on my machine” detonates in production with a UnicodeDecodeError the first time it meets an emoji or an accented name. The cure is a single keyword argument, applied every time:

text = "Aarav, Priya, Sofía, café, naïve, 北京\n"

with open("/tmp/names.txt", "w", encoding="utf-8") as f:
    f.write(text)

with open("/tmp/names.txt", "r", encoding="utf-8") as f:
    print(f.read())
Aarav, Priya, Sofía, café, naïve, 北京

Because both the write and the read pinned encoding="utf-8", the accented characters and the Chinese text survive the round trip exactly — on every platform.

Reading: four ways, and the one that scales

f.read()           # the whole file as one string — memory grows with file size
f.read(1024)       # the next 1024 characters
f.readline()       # one line, including its trailing newline
for line in f:     # iterate lines LAZILY — does not load the file
    ...

The last form is the one to internalise, because a file object is an iterator over its own lines. Writing for line in f streams the file a line at a time, which is the difference between code that scales and code that falls over. Watch it on a log file big enough that loading it whole would be reckless:

# First, fabricate a "log" file with 50,000 lines.
with open("/tmp/app.log", "w", encoding="utf-8") as f:
    for i in range(50_000):
        level = "ERROR" if i % 137 == 0 else "INFO"
        f.write(f"2026-05-28 14:{i % 60:02d}:00 {level} request id={i}\n")

# Stream it — count every ERROR line without ever loading the whole file.
error_count = 0
with open("/tmp/app.log", "r", encoding="utf-8") as f:
    for line in f:                       # one line at a time, constant memory
        if " ERROR " in line:
            error_count += 1
            if error_count <= 3:
                print(line.rstrip())     # rstrip drops the trailing newline

print(f"... and {error_count - 3} more")
print(f"total ERROR lines: {error_count}")
2026-05-28 14:00:00 ERROR request id=0
2026-05-28 14:17:00 ERROR request id=137
2026-05-28 14:34:00 ERROR request id=274
... and 362 more
total ERROR lines: 365

Every 137th line was an ERROR, so 365 of the 50,000 lines matched — and the loop found them all while holding only one line in memory at a time. The exact same code works identically on a 50 MB log or a 50 GB log, because the file object streams and the operating system handles the buffering underneath.

Newlines, and why a file might contain \r\n

In text mode (the default), Python normalises line endings for you. A file last edited on Windows may store \r\n on disk, yet for line in f hands you each line ending in a single \n. When you need the raw bytes untouched — say you are hashing a file for an integrity check — open it in binary mode, and Python translates nothing:

with open("config.txt", "rb") as f:     # binary mode = no newline translation
    raw_bytes = f.read()

Writing, buffering, and durability

f.write() does not always reach the disk immediately — Python and the OS buffer writes for speed, and closing the file flushes that buffer. That is yet another reason with is the right shape: the flush happens automatically on exit. But for a long-running writer — a service appending to a log over hours — you may want each line on disk promptly. Opening a text file with buffering=1 selects line buffering, flushing after every newline:

with open("/tmp/service.log", "a", encoding="utf-8", buffering=1) as f:
    for i in range(5):
        f.write(f"event {i}\n")
        # buffering=1 -> flush after every newline, so no explicit f.flush() needed.

with open("/tmp/service.log", "r", encoding="utf-8") as f:
    print(f.read())
event 0
event 1
event 2
event 3
event 4

In one breath

  • open(path, mode, encoding="utf-8") — pick the mode on purpose; 'w' truncates an existing file.
  • Always wrap open() in a with, so the file is closed (and flushed) on every exit path.
  • Always pass encoding="utf-8" — the platform default differs and breaks on non-ASCII text.
  • for line in f streams a file lazily in constant memory; .read() loads it whole.
  • Text mode normalises newlines to \n; binary mode ('rb') touches nothing.

Practice

Quick check

0/3
Q1What does mode 'w' do to an existing file?
Q2Why iterate `for line in f:` instead of `f.read().splitlines()`?
Q3Why pass `encoding='utf-8'` explicitly?

What’s next

Real codebases do not pass paths around as bare strings — they use pathlib. One import, and path handling stops being a stringly-typed mess of os.path.join calls.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is the difference between CPU-bound and I/O-bound work, and how does the choice affect concurrency strategy in Python?

CPU-bound work keeps the processor busy the whole time — matrix multiplication, compression, parsing. I/O-bound work spends most of its time waiting for a slow external resource — network, disk, database. The distinction directly determines which concurrency primitive to reach for: multiprocessing for CPU-bound (bypasses the GIL), threading or asyncio for I/O-bound (GIL released during waits).

When should you use threading versus multiprocessing in Python?

Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.

Write Python to read a CSV file line by line and compute a column aggregate without loading the entire file into memory.

Using the csv module with a generator or a running accumulator keeps memory use constant — O(1) space — regardless of file size. This matters when files are larger than available RAM, a common situation in data engineering pipelines.

What is a context manager and how does the with statement work?

A context manager is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — guaranteed, even if an exception is raised. This makes with the idiomatic way to manage resources like files, locks, and database transactions without leaking them.

Related lessons

Explore further

Skip to content