Reading a file is the easy half. Writing is where the interesting failure modes live: writes that disappear because the buffer never flushed, files that look different on Windows and Linux, partial writes that leave half-corrupt data behind, and opening with "w" when you meant "a". This lesson covers the two write methods on a file object, the difference between write and print, how newlines get translated in text mode, what buffering and flushing do, and the "write to a temp file then rename" pattern that protects you from crashes mid-write.
write() and writelines()A text file opened in write mode ("w", "a", or any mode with +) gives you a file object with two methods for putting data in: write and writelines. They're both straightforward, and they differ in one way.
Three calls, three lines on disk. write takes a string and writes it. That's the entire interface. It returns the number of characters written, which most code ignores.
The return value is sometimes useful for diagnostics on partial writes, but in everyday text-file code you can ignore it.
writelines is the second method, and the name is misleading. It takes an iterable of strings and writes each one. It does not add newlines between them. You're responsible for putting \n at the end of each string yourself.
The output is identical to three separate write calls. That's by design: writelines(lines) is equivalent to for line in lines: f.write(line). The performance difference is tiny because of buffering; both forms end up in the same in-memory buffer before going to disk.
The trap is forgetting that writelines doesn't add newlines. The following code looks correct but produces one long line:
That's the cost of the "writelines doesn't add newlines" rule. Two clean fixes: either include "\n" in each string when you build the list, or write a helper:
Either approach works. A common style is to build the strings with newlines included, so the data structure mirrors what hits the disk.
Both write and writelines go through Python's buffered I/O layer. Writing one byte at a time in a loop is fine; the buffer absorbs the calls and flushes to disk in larger chunks. Don't waste time concatenating into a large string to reduce write calls.
write vs print: Two Tools That Look SimilarThe print function writes to standard output by default, but it accepts a file keyword argument and can write to any file object. So you can produce the same output with either method:
The two approaches differ in a few specific ways that come up in practice.
| Behavior | write | print |
|---|---|---|
| Adds a newline | No. You write it. | Yes (controlled by end=). |
| Accepts multiple arguments | No. One string per call. | Yes. Joins with sep= (default space). |
Calls str() on non-strings | No. Raises TypeError. | Yes. Converts everything via str. |
| Flushes after each call | No. Buffered. | No, unless you pass flush=True. |
The "calls str() on non-strings" difference is the practical one. print(total, file=f) works even when total is a float, while f.write(total) raises:
The fix is f.write(str(total)) or f.write(f"{total}\n") or print(total, file=f). Any of them work. For occasional logging or quick one-off output, print(..., file=f) is often cleaner because it handles the conversion and the newline for you. For structured output where the exact bytes matter (writing CSV rows, generating a specific format), write with explicit formatting gives you more control.
print calls str() on every argument. That's a few extra function calls per line. For occasional output it's invisible. For tight loops writing millions of lines, write(f"{...}\n") is slightly faster.
A practical pattern: use print(file=f) for human-readable output where the default formatting fits, and write for formats where you control every byte.
Both work. Pick whichever reads more clearly for the task at hand.
Text mode does newline translation on the way out. When you call f.write("hello\n"), Python translates that \n into the platform's preferred line separator before the bytes hit disk. On Unix-like systems (macOS, Linux) that's \n. On Windows it's \r\n. The translation is hidden by default, which is usually convenient and occasionally a problem.
Same string written, different bytes on disk. Python does this so a file written on Windows opens cleanly in Notepad (which historically expected \r\n) and a file written on Linux opens cleanly in cat. Most of the time you don't care, because reading the file in text mode reverses the translation: any of \n, \r\n, or \r in the file is read back as \n.
The translation only matters when:
For those cases, control the translation explicitly with the newline argument to open. Passing newline="" disables translation entirely on write while still handling all line-ending styles cleanly on read.
The bytes match exactly across operating systems. This is the recommended setting whenever you use the csv module, because CSV fields can legally contain embedded \r\n sequences and you don't want Python rewriting them.
For everyday log files and config files, the default translation is fine. For format-sensitive output (CSV, custom binary, network protocols), pass newline="" and control the bytes yourself.
flush()When you call f.write("hello"), the bytes don't necessarily reach the disk right away. Python keeps an in-memory buffer and only flushes it when the buffer fills up, when you explicitly call flush, or when the file is closed. Buffering is what makes file I/O fast: a single 8 KB write to the OS is far cheaper than 8,000 separate one-byte writes.
Three buffering modes exist, controlled by the buffering keyword argument to open.
buffering= value | Mode | When data goes to disk |
|---|---|---|
0 | Unbuffered (binary mode only) | Every write is a system call. |
1 | Line-buffered (text mode only) | Every newline triggers a flush. |
> 1 | Block-buffered with this many bytes | When the buffer fills, or on flush/close. |
-1 (default) | Use the OS default | Typically 8 KB block buffering. |
For most code, the default fits. Two cases call for changing it.
A log file you want to monitor with tail -f should flush after every line. Without line buffering, an 8 KB buffer might hold an hour of low-volume log lines before flushing, which makes the log appear "stuck."
Without buffering=1, those two writes might sit in memory for several seconds (or until the program exits). With it, each newline forces a flush, so a tail of the file shows lines as they happen.
flush() for Critical PointsSometimes you want to flush at a specific moment, not after every line. The flush method does exactly that.
flush pushes the Python buffer down to the OS. That's enough to survive a Python crash, because the OS keeps its own page cache and the bytes will still get to disk eventually.
For durability against a power loss or OS crash, you need to push the OS to flush its own cache too. That's what os.fsync is for.
fsync is slow (it waits for the disk to commit), and most application code doesn't need it. Databases and financial systems do. For ordinary file work, flush is enough; for crash-safe writes, use the rename pattern below.
The diagram shows the two-stage path a write takes. Bytes leave your code, sit in Python's buffer, then move to the OS page cache when the buffer flushes, then to physical disk when the OS flushes (or you call fsync). A Python crash loses anything still in the Python buffer. An OS crash or power loss loses anything still in the OS page cache. Most code only cares about the first stage.
A problem with writing files: if your program crashes mid-write, the file on disk is half-written. Anyone reading it gets corrupt data. This matters because "the program crashed" doesn't have to mean "I wrote buggy code." A user might Ctrl-C the process. The OS might kill the process. The machine might lose power.
The "w" mode truncates the file the moment open runs. If the program crashes after that point but before all the writes finish, orders.txt exists but contains only part of the new data. Reopen the program and there's no reliable way to tell whether what's on disk is complete or partial.
The standard fix is to write to a temporary file in the same directory, then atomically rename it to the target name. On every major operating system, rename is an atomic operation: either the new name points to the new file, or it points to the old file, with no in-between state. A reader sees either the old version or the complete new version, never a partial state.
The function does four things:
orders.txt.tmp).fsync so the temp file is fully durable on disk.os.replace to atomically swap the temp file into the target name.orders.txt, if any, is now gone, replaced in a single OS operation.If the program crashes during step 1, you end up with an incomplete .tmp file and an intact original. Recovery is "delete the .tmp file." If the program crashes during step 3, the rename either happened or it didn't; there's no half-rename.
os.replace (added in Python 3.3) is the function to use for the rename. It works on both Windows and Unix, and it overwrites the destination if it already exists. Older code used os.rename, which fails on Windows if the destination exists.
The same pattern works for binary writes; use "wb" instead of "w". For most application code, this is the recommended way to write any file whose corruption would be a real problem: configuration files, generated reports, snapshots of state, anything that another part of the system might read while you're writing.
A few practical notes:
os.replace is atomic across filenames in the same directory; across filesystems it falls back to a copy-and-delete which isn't atomic.tempfile module in the standard library has helpers (tempfile.NamedTemporaryFile) that handle the temp-file creation for you.fsync call costs real time. Skip it if your data isn't critical; the rename itself is still atomic regardless.The other half of writing files is choosing between overwriting and appending. Here's the practical summary.
"w" is for "I want this file to contain exactly what I write." "a" is for "I want to extend what's already there." A common bug in this section is using "w" inside a function that's called repeatedly, where the intent was to accumulate but the actual behavior wipes the file each time.
Only the last event survives. Each call truncated the file. The fix is one character: change "w" to "a". "w" truncates and "a" appends.
Writing CSV, JSON, and other structured formats has dedicated modules (csv, json) in the standard library. But you can build simple structured output by hand with write and string formatting, which is sometimes the appropriate choice for one-off scripts or for formats that the standard library doesn't cover.
For tightly controlled data (no commas in field values, no embedded newlines, no quotes), produce a CSV file with write:
This works for clean data. The moment a product name contains a comma ("mug, large"), the hand-rolled writer produces invalid CSV. That's the case the csv module is for. Use this pattern only when you control the data shape and know it's safe.
For JSON, hand-rolling is even riskier (escaping rules are subtle), so always use the json module. This pattern still shows up in older codebases:
This produces valid JSON for this data, but it breaks the moment a product name contains a quote, a backslash, or a control character. Use the json module that handles all of this correctly.
The takeaway: for one-off scripts or quick exports, hand-rolling structured output is fine if the data shape is fully under your control. For anything ingesting data that might surprise you, use the standard library modules.
A few mistakes that come up repeatedly.
with and Losing Buffered WritesWithout close (or the with block that calls it for you), the buffered write might sit in Python's memory until garbage collection happens. On a normal exit that's fine, but on an abnormal exit (Ctrl-C, OS kill, segfault) the write is lost. The fix is with.
The write encodes é as the UTF-8 bytes \xc3\xa9. The read decodes those bytes as Latin-1, which interprets each one as a separate character, producing é instead of é. The output looks like text, which is the worst failure mode: no error, only wrong data. Use the same encoding on both sides, and prefer UTF-8 everywhere.
Text mode requires str. Either drop the b prefix or open the file in binary mode ("wb"). The bytes-vs-str split is enforced strictly; mixing them always raises.
writelinesThe most common writelines bug, discussed earlier: the method doesn't add newlines. Build them into the strings or use a for loop with f.write(line + "\n").
"w" When You Wanted to Append"w" wipes the file; "a" extends it. If your function is supposed to add to a log over time, use "a".
If a reader and a writer both have the same file open, the reader can see partial writes: a line missing its newline because the writer flushed mid-line, or even garbled bytes because the OS doesn't synchronize between processes. The fix is the atomic-write pattern: write to a temp file, then rename. The reader either sees the old version or the new one, never anything in between.
with. The fix is always the same: use a with block.encoding="utf-8". The default is platform-dependent; pass it explicitly.writelines without newlines in the strings. Either build them in or write a small for loop."w" when you meant "a". "w" truncates; "a" appends.10 quizzes