Chapter 3
Writing Text
Chapter 2 was about getting text out of a file. This chapter is the other direction. The modes are the same ones from Chapter 1, there are a couple of new methods, and there's one pattern worth committing to memory: how to replace a file without leaving a half-written mess if something goes wrong partway.
write
f.write takes a string, puts it in the file, and returns the number of
characters it wrote:
with open("note.txt", "w") as f:
written = f.write("Piranesi was excellent.\n")
print(f"wrote {written} characters")
wrote 24 charactersThe count includes the \n. That newline is there because you put it there.
f.write never adds one for you. Leave it off and the next write runs straight
on from the last one, on the same line.
Appending to a file
"w" mode empties the file the moment you open it, which you saw in Chapter 1.
When you want to keep what's there and add to the end, that's "a".
The real use for the reading log is adding a book you've just finished. Copy the log first so we're not editing the original:
with open("reading-log.csv") as src, open("work-log.csv", "w") as dst:
dst.write(src.read())
with open("work-log.csv", "a") as f:
f.write("Dune,Frank Herbert,5,2026-09-05\n")
with open("work-log.csv") as f:
lines = f.readlines()
print(f"{len(lines)} lines now")
print(lines[-1], end="")
12 lines now
Dune,Frank Herbert,5,2026-09-05The new row lands at the end, after the ten that were already there. Note the
\n again. Without it, Dune would have been stuck onto the end of the previous
line.
Writing several lines
Two ways to write more than one line at a time.
f.writelines takes a list of strings and writes them one after another. The
name is misleading: it does not add newlines, so the strings need to carry their
own.
print can write to a file instead of the screen by passing file=. It behaves
like the print you know: it converts its arguments to strings and adds a
newline at the end.
summary = [
"Reading summary\n",
"===============\n",
"Books finished: 8\n",
]
with open("summary.txt", "w") as f:
f.writelines(summary)
print("Average rating: 4.3", file=f)
with open("summary.txt") as f:
print(f.read(), end="")
Reading summary
===============
Books finished: 8
Average rating: 4.3writelines handled the three lines that already had their \n. print added
the newline on the last one itself.
Newlines and platforms
You write \n. On Windows, Python's text mode may store that on disk as \r\n,
the older two-character line ending. When you read the file back in text mode you
get plain \n again, so most of the time you never notice and never need to care.
The rule that follows from this: write \n and let Python handle the rest. Don't
write \r\n yourself, and don't try to strip \r when reading. The one place
this translation gets in the way is the csv module, which is why Chapter 7
opens its files with newline="".
When does the write actually hit disk?
Not always straight away. Python and the operating system hold recent writes in memory and flush them out in batches, because a thousand tiny disk writes are far slower than one big one.
Closing the file flushes whatever is buffered. That's what the with block does
for you on the way out, and it's the guarantee you normally rely on: once the
block ends, the file on disk is complete.
If you need the data on disk before the block ends, for instance in a
long-running program that another tool is watching, call f.flush():
with open("progress.txt", "w") as f:
f.write("started\n")
f.flush()
# ... a long job runs here ...
f.write("done\n")
with open("progress.txt") as f:
print(f.read(), end="")
started
doneThere's a stronger step, os.fsync(f.fileno()), which pushes past the operating
system's own cache to the physical disk. You rarely need it, and when you do you
will know why.
Replacing a file safely
Here's the pattern to memorize. Say you want to rewrite settings.json with a
new value. The obvious way has a hole in it:
with open("settings.json", "w") as f: # the old file is gone right here
f.write(new_contents) # if this crashes, you have nothing
"w" empties the file on open. If your program dies between that line and a
finished write, the old settings are gone and the new ones were never saved.
The fix is to write the new content to a separate temporary file first, then rename it over the original:
import os
def write_atomically(path, text):
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
os.replace swaps the name from the temp file to the real one in a single step
that cannot half-happen. Anyone reading settings.json at that moment gets
either the whole old file or the whole new file, never a mixture, and never an
empty one.
with open("settings.json") as f:
original = f.read()
updated = original.replace('"Sam"', '"Sam Ellison"')
write_atomically("settings.json", updated)
with open("settings.json") as f:
print(f.read(), end="")
{
"reader": "Sam Ellison",
"books_per_year_goal": 24,
"formats": ["paper", "ebook", "audio"]
}We're editing the file as raw text here, with str.replace, because parsing JSON
properly is Chapter 8. The write_atomically helper comes back in that chapter
and again in Chapter 12. Chapter 4 will add an encoding argument to it; the
default is fine for the plain text we have so far.
Common mistakes
Forgetting the newline. f.write("line one") then f.write("line two")
gives you line oneline two on a single line. Every line you write needs its own
\n, or use print(..., file=f), which adds one.
Writing a value that isn't a string.
with open("count.txt", "w") as f:
f.write(8)
f.write wants a string. Pass it a number and you get TypeError: write()
argument must be str, not int. Convert it first with str(8), or use
print(8, file=f), which converts for you.
Opening in "w" to append. Worth saying twice: "w" throws the old contents
away on open. Adding to a file is "a".
Practice
Try each of these before you read the solution under it. They all reuse the
write_atomically helper from earlier in the chapter.
- Write
append_row(path, fields)that appends one CSV row to a file, joining a list of strings with commas and adding the newline. - Write
number_lines(path)that rewrites a file with each line prefixed by its number (1:,2:, ...), usingwrite_atomicallyso a crash cannot lose the original. - Write
write_with_backup(path, text)that saves the current contents topath + ".bak"before writing the new text, both writes atomic.
Solutions
import os
def write_atomically(path, text):
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.write(text)
os.replace(tmp, path)
1.
def append_row(path, fields):
with open(path, "a") as f:
f.write(",".join(fields) + "\n")
with open("reading-log.csv") as src, open("log2.csv", "w") as dst:
dst.write(src.read())
append_row("log2.csv", ["Dune", "Frank Herbert", "5", "2026-09-05"])
append_row("log2.csv", ["Neuromancer", "William Gibson", "4", "2026-09-08"])
with open("log2.csv") as f:
print(len(f.readlines()), "rows")
13 rows2. enumerate(lines, start=1) pairs each line with its number.
def number_lines(path):
with open(path) as f:
lines = f.readlines()
numbered = ""
for i, line in enumerate(lines, start=1):
numbered += f"{i}: {line}"
write_atomically(path, numbered)
with open("small.txt", "w") as f:
f.write("first\nsecond\nthird\n")
number_lines("small.txt")
with open("small.txt") as f:
print(f.read(), end="")
1: first
2: second
3: third3. Read the old text first, since the second write replaces it.
def write_with_backup(path, text):
with open(path) as f:
old = f.read()
write_atomically(path + ".bak", old)
write_atomically(path, text)
with open("config.txt", "w") as f:
f.write("version 1\n")
write_with_backup("config.txt", "version 2\n")
with open("config.txt") as f:
print("now:", f.read().strip())
with open("config.txt.bak") as f:
print("backup:", f.read().strip())
now: version 2
backup: version 1Where this leaves you
You can create files, append to them, write one line or many, and you have a safe way to replace a file's contents without risking the old version. Chapter 4 is the thing every example so far has quietly assumed: that the text in these files is plain enough to read without telling Python how it was encoded.