The with Statement and open() in Depth
One keyword. One argument. Zero resource leaks.

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 blockopen("notes.txt", "w"): returns a file object that knows how to manage itselfas file: binds the file object to the namefileso you can use itthe 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:
Find the file at the given path
Reserve a file descriptor for your process
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:
Always use
withwhen opening files. Never rely on.close()or the garbage collector.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





