Writing files without losing them
Truncate or append on purpose, write real lines, and rename into place so a crash cannot corrupt a file.
Reading a file is safe. Writing one is not
Every tutorial teaches open for reading, and reading has one real failure mode: the file is not there. Writing has several, and they destroy data rather than raising. You can empty a file you meant to add to, produce a file with no line breaks in it, leave a half-written file behind when a process is killed, or write something that never reaches the disk at all because you never closed the handle.
None of these are exotic. They are the ordinary results of the defaults, and each has a one-line fix.
The exercises here really do write files. The editor runs Python on an in-memory filesystem, so anything you create lives for the length of the run and then vanishes, which makes it a safe place to practise the real calls.
Pick the mode on purpose
The Context managers, JSON and CSV lesson has the full mode table, and the row worth carrying here is the destructive one: open(path, "w") empties the file the instant it opens it, before your first write. open(path, "a") keeps what is there and puts every write at the end.
| Way to write | What it does | Reach for it when |
|---|---|---|
| Path.write_text(s) | opens, truncates, writes, closes, all in one call | the whole content already fits in one string |
| open(p, 'w') | truncates on open, then writes as you go | you are streaming rows and cannot hold them all |
| open(p, 'a') | keeps the file, every write lands at the end | you are adding to a log or an audit trail |
| temp file, then os.replace | readers see the old file until the instant it becomes the new one | a half-written file would be worse than a stale one |
write adds nothing you did not ask for
write puts exactly the characters you hand it into the file. No newline, no separator, no trailing anything:
from pathlib import Path
path = Path("report.txt")
with open(path, "w", encoding="utf-8") as out:
for line in ["alpha", "beta"]:
out.write(line + "\n") # the \n is yours to add
Path("small.txt").write_text("alpha\nbeta\n", encoding="utf-8")
Path.write_text is the shortest correct way to write a whole small file: it opens, truncates, writes, and closes in one call, and it takes the same encoding= argument you should always pass.
Writing CSV needs newline=""
import csv
with open("out.csv", "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["name", "age"])
writer.writerows([["Ada", 30], ["Alan", 41]])
The csv module writes its own line endings. Opening in text mode without newline="" lets the file layer translate them a second time, and you get a blank line between every row. The same argument belongs on the read side for the same reason.
Write to a temp name, then rename
A rename within one filesystem is atomic: a reader sees either the old file or the new one, never a half-written one. That single property is what makes a config or index file safe to regenerate while something else is reading it.
import os
from pathlib import Path
def write_atomically(path, text):
temp = Path(str(path) + ".tmp")
temp.write_text(text, encoding="utf-8")
os.replace(temp, path) # atomic: readers see old or new, never partial
Pitfalls
- Always use
with. It closes the handle on the way out, and closing is what flushes your writes to disk. Without it, a crash between the write and the close loses everything you wrote. Path.write_texttruncates too. It is a whole-file write, not an append, so calling it twice leaves you with only the second call's content.- Create the parent first. Writing to
reports/2026/out.txtraisesFileNotFoundErrorwhenreports/2026does not exist.path.parent.mkdir(parents=True, exist_ok=True)first.
Interview nuance: "how would you make that write safe?" is a real system-design question at file scale, and the answer is the temp-then-rename pattern. Name the property you are buying, which is that a reader can never observe a partial state, and note that it only holds when the temp file is on the SAME filesystem as the target, because a rename across filesystems is a copy plus a delete and is not atomic. That is the same reasoning that makes an atomic index swap or a blue-green cutover work.
import tempfile
from pathlib import Path
folder = Path(tempfile.mkdtemp())
report = folder / "report.txt"
with open(report, "w", encoding="utf-8") as out:
for line in ["alpha", "beta"]:
out.write(line + "\n")
with open(report, "a", encoding="utf-8") as out:
out.write("gamma\n")
print(repr(report.read_text(encoding="utf-8"))) # 'alpha\nbeta\ngamma\n'
print(report.read_text(encoding="utf-8").splitlines())Apply
Your turn
The task this lesson builds to.
Write a function save_lines(lines) that writes each string in lines to a file as its own
line, then returns the file's full contents as one string.
For ["alpha", "beta"] return "alpha\nbeta\n". Every line ends in a newline, including the last,
and an empty list produces an empty file. Create somewhere private to write with
tempfile.mkdtemp().
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
An audit log has to survive the code that writes to it. A deploy script opened the log in "w"
mode to add a single line, and a month of history went with it, because "w" empties the file
before the first write lands.
Implement append_event(existing, event): write each string in existing to a fresh file as its own
line, then add event as one more line WITHOUT rewriting what is already there, and return the
file's lines as a list of strings.
For existing = ["login"] and event = "logout" return ["login", "logout"].
3 hints and 4 automated checks are waiting in the workspace.