Skip to main content

Command Palette

Search for a command to run...

The with Statement and open() in Depth

One keyword. One argument. Zero resource leaks.

Published
10 min read
The with Statement and open() in Depth
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back — every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

In Part 1, we established the problem: resources need to be returned, code between acquiring and releasing can fail, and try/finally is the manual safety net. Now let's look at the cleaner solution Python provides, and go deep on the most common context manager you'll ever use.


The with Statement: Clean Resource Management

Here's the try/finally pattern from Part 1:

file = open("notes.txt", "w")
try:
    file.write("Hello from Python")
finally:
    file.close()

And here's the same thing with with:

with open("notes.txt", "w") as file:
    file.write("Hello from Python")
# file is automatically closed here — no matter what

Same guarantee, half the code, and crucially, no way to forget the cleanup. The with statement handles it for you.


Anatomy of the with Statement

Let's name every piece:

with open("notes.txt", "w") as file:
    file.write("Hello from Python")
with  EXPRESSION  as  NAME:
         │              │
         │         the object you use inside the block
         │
something that returns a context manager
  • with: the keyword that opens a managed block

  • open("notes.txt", "w"): returns a file object that knows how to manage itself

  • as file: binds the file object to the name file so you can use it

  • the indented block: you code runs here

  • after the block: cleanup happens automatically, whether you left normally or via an exception

The as clause is optional. Some context managers do their job without needing to reference the object:

import threading

lock = threading.Lock()

with lock:  # no `as` needed — you just want acquire/release behavior
    print("only one thread runs this at a time")

Proving the Guarantee Holds on Error

Don't take the guarantee on faith. Let's verify it:

try:
    with open("notes.txt", "w") as file:
        file.write("first line\n")
        raise ValueError("something exploded")
        file.write("second line\n")  # never runs
except ValueError:
    pass

print(file.closed)  # True — closed despite the error

Compare that to the broken version without with:

try:
    file = open("notes.txt", "w")
    file.write("first line\n")
    raise ValueError("something exploded")
    file.close()  # never runs
except ValueError:
    pass

print(file.closed) # False - still open, leaked

Run both. The difference is clear. file.closed is your proof.


The JavaScript Parallel

Coming from Javascript, this pattern should feel familiar. The with statement is conceptually identical to try/finally, which you've probably written in Node.js. There's also the TC39 Explicit Resource Management proposal (already in TypeScript 5.2+) that introduces a using keyword with the same intent:

// TypeScript 5.2+ — using keyword
await using file = await fs.open("notes.txt", "w");
// file auto-disposes when block exits

Python has had this built in since 2.5 (2006, via PEP 343). The JavaScript ecosystem is just now catching up, which says something about how useful the pattern is.


Deep Dive: open()

open() is the context manager you'll reach for most often. Let's understand it fully, not just the happy path.

What open() Actually Does

When you call open(), Python asks the OS to:

  1. Find the file at the given path

  2. Reserve a file descriptor for your process

  3. Return a file object that wraps that descriptor

That file object is what as file binds. It's not the file's contents, it's a handle you use to interact with the file. Think of it like a TV remote: the remote isn't the TV, but it's what lets you control it.

The Full Signature

open(file, mode='r', encoding=None, buffering=-1, errors=None)

The two parameters you'll use constantly are file and mode.

Parameter 1: file - What to Open

open("notes.txt")                       # looks in current working directory
open("data/notes.txt")                  # relative path
open("/home/moussa/projects/notes.txt") # absolute path

If the file doesn't exist and you're trying to read it.

with open("ghost.txt", "r") as f:
    content = f.read()
# FileNotFoundError: [Errno 2] No such file or directory: 'ghost.txt'

If you're writing, Python creates the file automatically.

Parameter 2: mode - What You Intend to Do

The mode tells Python (and the OS) how you want to interact with the file. It matters as the OS grants different permissions based on intent.

Mode Name Behavior
"r" read Default. The file must exist and the cursor is at the file start.
"w" write It creates the file if it doesn't exist yet and writes to it. If the file exists, it wipes existing content.
"a" append It creates the file if it doesn't exist yet, writes to it and add to its ends. It basically appends and never wipes.
"x" exclusive create It creates the file and fails if the file already exists.
"r+" read + write The file must exist. It allows you to both read and write.

You combine modes with a type modifier:

Modifier Meaning
"b" Binary mode: raw bytes, no encoding
"t" Text mode: It's the default and it handles encoding.
open("image.png", "rb")   # read binary — for images, PDFs, etc.
open("data.bin", "wb")    # write binary
open("notes.txt", "rt")   # read text — same as just "r"

The Mode That Silently Destroys Your Data

"w" is the mode most beginners get burned by. It doesn't append, it destroys the existing content the moment you open the file, before you've written a single byte:

# First write
with open("log.txt", "w") as f:
    f.write("Entry 1\n")

# This WIPES "Entry 1" completely
with open("log.txt", "w") as f:
    f.write("Entry 2\n")

# log.txt now contains only "Entry 2" — Entry 1 is gone

If you want to keep existing content and add to it, use "a":

with open("log.txt", "a") as f:
    f.write("Entry 2\n")  # Entry 1 is still there

Parameter 3: encoding - Always Specify It

This is the one that causes bugs across operating systems. When you don't specify encoding, Python uses the system default, which differs by platform:

  • Linux / macOS: UTF-8 (almost always)

  • Windows: A legacy encoding like cp1252, which cannot represent most Unicode characters.

According to PEP 597, of the 4,000 most downloaded packages on PyPI, 82 fail to install from source on Windows due to missing encoding specification, and that's just the ones with non-ASCII characters in their README.

# Risky — encoding depends on the OS
with open("notes.txt", "r") as f:
    content = f.read()

# Explicit — works the same everywhere
with open("notes.txt", "r", encoding="utf-8") as f:
    content = f.read()

One character can prevent a whole class of cross-platform bugs. Always specify encoding="utf-8".

Note: Python 3.15 will make UTF-8 the default via PEP 686. Until then, specify it explicitly.


Reading and Writing: The Core Methods

Once you have a file object, these are the methods you'll use day to day.

Reading

# Read the entire file as one string
with open("notes.txt", "r", encoding="utf-8") as f:
    content = f.read()
    print(content)

# Read one line at a time
with open("notes.txt", "r", encoding="utf-8") as f:
    first_line = f.readline()
    print(first_line)
    second_line = f.readline()
    print(second_line)
    

# Read all lines into a list
with open("notes.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()
    print(lines)  # ["line 1\n", "line 2\n", ...]

The most Pythonic way to read line by line is to iterate directly over the file object. It's memory-efficient as it doesn't load the entire file at once, which matters when you're processing large files:

with open("notes.txt", "r", encoding="utf-8") as f:
    for line in f:            # file objects are iterable
        print(line.strip())   # strip() removes the trailing \n

Writing

# Write a string — \n is your responsibility
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Hello, Moussa\n")
    f.write("Second line\n")

# Write a list of strings at once
with open("notes.txt", "w", encoding="utf-8") as f:
    lines = ["line 1\n", "line 2\n", "line 3\n"]
    f.writelines(lines)

Note: write() does not add a newline automatically. You have to include \n yourself. This catches people coming from print(), which adds it for you.


The File Cursor

Every file object tracks your current position using an internal cursor. This trips people up when they try to read a file they just wrote:

with open("notes.txt", "r", encoding="utf-8") as f:
    first_five = f.read(5)   # reads first 5 characters
    print(first_five)        # "Hello"

    rest = f.read()          # cursor is now at position 5
    print(rest)              # reads everything after position 5

You can move the cursor manually with seek():

with open("notes.txt", "r", encoding="utf-8") as f:
    f.read()         # reads everything — cursor now at end
    print(f.read())  # empty string — nothing left

    f.seek(0)        # reset cursor to the beginning
    print(f.read())  # reads everything again

Why open() Is a Context Manager

Here is the connection back to where we started. File objects implement two special methods: __enter__ and __exit__ which is what makes them work with with.

Simplified, this is what they do:

class FileObject:
    def __enter__(self):
        return self     # return the file object itself

    def __exit__(self, exc_type, exc_value, traceback):
        self.close()    # always close the file
        return False    # never suppress exceptions

That's it. When Python sees with open(...) as f, it calls __enter__ to get the file object and __exit__ to close it. Everything else follows from that.

We'll look at how __enter__ and __exit__ actually work, and how to implement them yourself in Part 3.


Quick Reference

# Read entire file
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()

# Write (creates or overwrites)
with open("data.txt", "w", encoding="utf-8") as f:
    f.write("some content\n")

# Append (never overwrites)
with open("data.txt", "a", encoding="utf-8") as f:
    f.write("new line\n")

# Read line by line (memory efficient)
with open("data.txt", "r", encoding="utf-8") as f:
    for line in f:
        print(line.strip())

# Read binary (images, PDFs, etc.)
with open("image.png", "rb") as f:
    data = f.read()

The Mental Model to Take Away

The with statement activates a contract: setup on entry, guaranteed teardown on exit. open() is the most common thing that honors that contract. It opens a file descriptor on entry and closes it on exit, no matter what happens in between.

Two rules to carry forward:

  1. Always use with when opening files. Never rely on .close() or the garbage collector.

  2. Always specify encoding=utf-8. Never rely on the OS default.

In part 3, we'll pull back the curtain on how the with statement works under the hood and build your own context manager from scratch.

Thank you for reading! 🙂


This is Part 2 of 5 in the Python Context Managers series.

New here? Read Part 1

Next up: Part 3 - Generator Functions in Python

Python Context Managers: A Deep Dive

Part 3 of 4

A five-part series on Python context managers, built for developers coming from JavaScript. We start with the resource leak problem, work through the with statement, open(), generator functions, and building your own context managers, then finish with real-world usage across popular frameworks.

Up next

Why Python Makes you "Close" Things (And What Happens When You Don't)

Every open() is a debt. Here's what happens when you don't pay it back