PY-01-014-P · Primer — Files & the with Statement
The idea in one sentence
open() gives you a live connection to a file on disk — you tell it a mode like "r" to read or "w" to write — and wrapping that connection in a with block guarantees the file is closed and cleaned up automatically the instant you're done, even if something goes wrong in the middle.
Why it exists
Everything your programs have touched so far has lived in memory and vanished the moment the program ended. But real work needs to persist: a config that survives a restart, a log you can read tomorrow, a report you email to a colleague. Files are how data outlives a single run, and open() is the door to them. The catch is that a file isn't just data — it's a resource the operating system hands out and expects back. Leave files open and you leak file handles, risk losing writes that were still sitting in a buffer, and eventually hit the OS limit and crash. So the old, error-prone way was "open the file, do your work, and remember to call .close() at the end" — and the day your code raised an exception before reaching that .close(), the file stayed open and half-written. The with statement exists to make forgetting impossible: it ties the file's cleanup to the block itself, so the moment you leave the block — normally or by an error — Python closes the file for you. That is why with open(...) is the one true way Python programmers open files.
How it works
You call open(path, mode) and immediately hand it to a with block: with open("notes.txt", "w") as f:. The mode string picks what you're allowed to do — "r" reads (and is the default), "w" writes and erases any existing contents first, and "a" appends to the end of an existing file. Inside the block, f is the open file object: f.write(text) sends a string to it, and f.read() pulls the whole file back as one string. When execution reaches the end of the with block — whether it got there cleanly or because a line raised an exception — Python automatically calls f.close() for you, flushing any buffered writes to disk and returning the handle to the OS. That automatic cleanup is the whole point of a context manager: with manages the file's lifetime so you never have to. Here it is end to end:
# Write three lines to a text file, then read them back.
with open("notes.txt", "w") as f: # "w" = write; creates/overwrites the file
f.write("first line\n")
f.write("second line\n")
# <- file is now closed automatically, even if writing had raised an error
with open("notes.txt", "r") as f: # "r" = read (the default mode)
text = f.read() # read the whole file into one string
print(text)
print("closed?", f.closed) # True -> 'with' cleaned it up for us
# first line
# second line
#
# closed? True
The one gotcha
Opening a file in "w" mode wipes it clean before you write a single byte. "w" doesn't mean "let me write to this file," it means "make this file empty and then let me write" — so if you meant to add to an existing file and reached for "w", you just deleted everything that was there. The fix is to know your three everyday modes cold: "r" reads and never changes the file, "w" creates-or-truncates (destroying existing contents), and "a" appends to the end while keeping what's already there. Watch the difference bite:
with open("log.txt", "w") as f: # "w" ERASES any existing file first
f.write("run 1\n")
with open("log.txt", "w") as f: # opens "w" AGAIN -> wipes "run 1"!
f.write("run 2\n")
print(open("log.txt").read()) # run 2 (run 1 is gone)
with open("log.txt", "a") as f: # "a" = append -> keeps what's there
f.write("run 3\n")
print(open("log.txt").read()) # run 2 / run 3
The rule: reach for "a" when you want to keep and extend a file (like a log), and reach for "w" only when you genuinely want a fresh, empty file. When in doubt, ask yourself "do I want what's already in this file to survive?" — if yes, it is never "w".
See it — how with picks a mode and always cleans up
flowchart TD
A["with open(path, mode)"] --> B{"Which mode?"}
B -->|"r (read)"| C["Read contents<br/>file unchanged"]
B -->|"w (write)"| D["Erase file first,<br/>then write"]
B -->|"a (append)"| E["Keep file,<br/>add to the end"]
C --> F["Leave the<br/>with block"]
D --> F
E --> F
F --> G{"Error along<br/>the way?"}
G -->|"Yes"| H["Still closes<br/>the file"]
G -->|"No"| I["Closes the file"]
H --> J["Handle freed,<br/>writes flushed"]
I --> J
The one-line takeaway
Always open files with with open(path, mode) as f: — pick "r" to read, "a" to append, and "w" only when you truly want to wipe the file first — and with guarantees the file is flushed and closed for you no matter what happens inside.
Where you'll meet it
You'll meet with open(...) the first time a program needs to remember anything: saving user settings, appending to a log file, reading a .csv of data to process, or writing out a generated report. The with-context-manager pattern reaches far beyond files, too — you'll see the exact same shape managing database connections (with connection:), network sockets, thread locks, and file-like uploads, because "acquire a resource, use it, guarantee it's released" is one of the most common jobs in real software. In interviews, opening a file without with (or forgetting to close it) is a classic tell that someone learned Python from old tutorials, while with open(...) signals you understand resource safety. And the "w"-truncates-your-file gotcha is a genuine rite of passage: nearly every Python programmer has overwritten a file they meant to append to exactly once — and after this primer, ideally never again.
Named concepts: open modes for read/write, reading and writing text, the with context manager, automatic resource cleanup
