Skip to main content

Writing files without losing them

Level 2: Level 2: Idiomsmedium13 minfilesfile-iopathlibio

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.

Table
The first three differ in what they destroy. The fourth differs in what a reader can ever observe, which is the property that matters once something else is reading the file while you write it.
Way to writeWhat it doesReach for it when
Path.write_text(s)opens, truncates, writes, closes, all in one callthe whole content already fits in one string
open(p, 'w')truncates on open, then writes as you goyou are streaming rows and cannot hold them all
open(p, 'a')keeps the file, every write lands at the endyou are adding to a log or an audit trail
temp file, then os.replacereaders see the old file until the instant it becomes the new onea half-written file would be worse than a stale one
The first three differ in what they destroy. The fourth differs in what a reader can ever observe, which is the property that matters once something else is reading the file while you write it.

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")
Check yourself
out.writelines(['alpha', 'beta', 'gamma']) runs against a file opened in w mode. How many lines does the file have afterwards?

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]])
Check yourself
A csv.writer writes to a file opened with open(path, 'w') on Windows, with no newline argument. What does the output look like?

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
Check yourself
A job writes the new config to config.json.tmp and is then killed, before it reaches os.replace. What does the service read from config.json?

Pitfalls

Check yourself
Code calls out = open(path, 'w'), then out.write('done'), with no with block and no close. The process is killed a second later. What does another process reading the file find?
  • 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_text truncates 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.txt raises FileNotFoundError when reports/2026 does 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.

Worked example (Python)
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.