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")
Output
wrote 24 characters

The 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="")
Output
12 lines now
Dune,Frank Herbert,5,2026-09-05

The 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="")
Output
Reading summary
===============
Books finished: 8
Average rating: 4.3

writelines 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="")
Output
started
done

There'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="")
Output
{
  "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.

  1. Write append_row(path, fields) that appends one CSV row to a file, joining a list of strings with commas and adding the newline.
  2. Write number_lines(path) that rewrites a file with each line prefixed by its number (1:, 2:, ...), using write_atomically so a crash cannot lose the original.
  3. Write write_with_backup(path, text) that saves the current contents to path + ".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")
Output
13 rows

2. 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="")
Output
1: first
2: second
3: third

3. 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())
Output
now: version 2
backup: version 1

Where 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.