# The with Statement and open() in Depth

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:

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

And here's the same thing with `with`:

```python
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:

```python
with open("notes.txt", "w") as file:
    file.write("Hello from Python")
```

```plaintext
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:

```python
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:

```python
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`:

```python
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](https://github.com/tc39/proposal-explicit-resource-management) (already in TypeScript 5.2+) that introduces a `using` keyword with the same intent:

```typescript
// 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

```python
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

```python
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.

```python
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. |

```python
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:

```python
# 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"`:

```python
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,](https://peps.python.org/pep-0597/) 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.

```python
# 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.](https://peps.python.org/pep-0686/) 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

```python
# 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:

```python
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

```python
# 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:

```python
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()`:

```python
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:

```python
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

```python
# 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**](https://blog.moussaamzat.dev/why-python-makes-you-close-things)
> 
> Next up: **Part 3 - Generator Functions in Python**
