Files are how a script remembers anything after it exits. My first file-handling code skipped the with block, skipped the encoding, and once lost a log I actually needed. This post is the version I'd hand my past self: the modes that cover real life, and why with is not optional.
with open("notes.txt", "w", encoding="utf-8") as f: # write from scratch
f.write("bought coffee beans\n")
f.write("call the dentist\n")
with open("notes.txt", "a", encoding="utf-8") as f: # append to the end
f.write("renew library card\n")
Three things worth noticing. The mode is the second argument: "w" writes from scratch, "a" adds to the end. Every write() needs its own \n, because write() adds nothing on its own. And encoding="utf-8" is there on purpose — hold that thought for the end.
The mode that cost me a log file: "w" empties the file immediately, before a single byte of new data is written. If the script then crashes halfway through, you're left with a truncated file and good intentions. Anything that accumulates over days — a log, a notes file — belongs to "a".
with guarantees the file gets closed when the block ends — including when an error happens inside it. The manual version has no such promise:
f = open("notes.txt", "w", encoding="utf-8")
f.write("half a line")
f.close() # never runs if write() raises
One exception between open and close, and the handle leaks — on Windows the file can even stay locked against other programs. And there's a subtler loss: writes sit in a buffer, and close() is what flushes them to disk. My lost log wasn't deleted; it was still sitting in memory when the script crashed, and the flush never happened. The file existed and was empty — a bug with no obvious suspect.
with open("notes.txt", encoding="utf-8") as f:
contents = f.read() # the whole file as one string
# 'bought coffee beans\ncall the dentist\nrenew library card\n'
with open("notes.txt", encoding="utf-8") as f:
for i, line in enumerate(f, start=1):
print(i, line.rstrip())
# 1 bought coffee beans
# 2 call the dentist
# 3 renew library card
read() is right for small files. For anything that might be big, loop instead — Python streams the lines one at a time and never holds the whole file in memory. Each line still carries its trailing \n, which is what rstrip() is for: without it, print's own newline doubles the gap between lines.
Put together — append to write, loop to read — this is a shape I keep on every machine:
def add_task(text):
with open("todo.txt", "a", encoding="utf-8") as f:
f.write(text + "\n")
def show_tasks():
with open("todo.txt", encoding="utf-8") as f:
for line in f:
print("-", line.rstrip())
add_task("water the plants")
add_task("back up the blog")
show_tasks()
# - water the plants
# - back up the blog
Append mode even creates the file if it doesn't exist yet, so the first task of the day just works.
Always pass encoding="utf-8". Without it, Python uses your operating system's default, and those defaults differ between machines — my Windows laptop and the little Linux box that runs my backups once disagreed about a notes file, and every apostrophe in it lost. UTF-8 everywhere turned the problem from occasional to impossible. For small files, my post on pathlib shows the one-line shortcuts, read_text() and write_text() — the same advice about encoding applies there.