Writing data to a file feels like one of the simplest things you can do in Python — until you’ve lost data to a crash mid-write, corrupted a file by writing binary data in text mode, or watched a script grind to a halt because it was writing one line at a time with no buffering strategy. Over the years I’ve built everything from simple log writers to full data-export pipelines, and I’ve picked up habits around persisting data that go well beyond open(file, 'w').write(data). This guide covers all of it.
The Basics: Opening a File for Writing
with open('output.txt', 'w') as file:
file.write('Hello, this is my first line.\n')
file.write('And this is the second line.\n')
I always use the with context manager here for the same reason I do when reading: it guarantees the file is properly closed and flushed to disk, even if an exception interrupts the write partway through.
Write Modes: Choosing the Right One Matters
open('file.txt', 'w') # write - truncates existing content, creates if missing
open('file.txt', 'a') # append - adds to the end, creates if missing
open('file.txt', 'x') # exclusive creation - fails if the file already exists
open('file.txt', 'wb') # write binary
open('file.txt', 'w+') # write and read
The mode I most often get wrong when I’m rushing is 'w' versus 'a'. Opening a file in 'w' mode immediately truncates it — even before you write a single byte, the existing content is gone the moment the file opens. I’ve accidentally wiped out log files this way by opening them in the wrong mode during debugging.
# This truncates existing_log.txt to zero bytes immediately upon opening,
# even if an exception happens before any write() call
with open('existing_log.txt', 'w') as file:
pass # existing_log.txt is now empty
'x' mode has become a habit of mine for scripts that generate output files I don’t want accidentally overwritten — it raises FileExistsError if the target already exists, forcing me to consciously decide what to do instead of silently clobbering data.
try:
with open('report.txt', 'x') as file:
file.write('New report content')
except FileExistsError:
print("report.txt already exists - refusing to overwrite.")
Writing Multiple Lines Efficiently
lines = ['First line\n', 'Second line\n', 'Third line\n']
with open('output.txt', 'w') as file:
file.writelines(lines)
writelines() doesn’t add newlines automatically — I have to include \n in each string myself, which trips people up who assume it behaves like print().
For building up content programmatically, I usually construct a list and join it rather than calling write() in a tight loop:
records = [f"record_{i},{i*2}\n" for i in range(1000)]
with open('output.txt', 'w') as file:
file.write(''.join(records))
Internal Working: Buffering and Why It Matters
This is the part that genuinely changed how I write performance-sensitive file code. When you call file.write(), Python doesn’t necessarily send those bytes straight to disk. By default, Python’s file objects use buffered I/O — writes accumulate in an in-memory buffer, and the actual system call to write to disk only happens when the buffer fills up, when you explicitly call file.flush(), or when the file is closed.
This buffering exists because disk I/O (and even more so, network I/O) is orders of magnitude slower than memory operations. Making a system call for every single write() — especially in a loop writing thousands of small strings — would be catastrophically slow compared to batching those writes together and flushing periodically.
import time
# Slow-ish: many small writes, though still buffered internally
start = time.perf_counter()
with open('test1.txt', 'w') as file:
for i in range(100000):
file.write(f"line {i}\n")
print(f"Individual writes: {time.perf_counter() - start:.4f}s")
# Faster: build the string once, write it in a single call
start = time.perf_counter()
content = ''.join(f"line {i}\n" for i in range(100000))
with open('test2.txt', 'w') as file:
file.write(content)
print(f"Single joined write: {time.perf_counter() - start:.4f}s")
Even though Python’s own buffering already helps the first version, the second version is consistently faster in my testing, because it avoids the Python-level function-call overhead of invoking write() 100,000 separate times, in addition to reducing the number of underlying system calls.
I can control buffering explicitly via the buffering parameter of open():
open('file.txt', 'w', buffering=1) # line buffering (flushes on every newline)
open('file.txt', 'w', buffering=8192) # fixed buffer size in bytes
open('file.txt', 'w', buffering=0) # unbuffered (binary mode only)
I use buffering=1 (line buffering) specifically for log files where I want each log entry to hit disk promptly, in case the process crashes before a normal close/flush happens.
Forcing Data to Disk: flush() and fsync()
Buffering means there’s a window where data exists in memory but not yet physically on disk. For anything I truly cannot afford to lose — critical application state, financial records — I go further than just flush():
import os
with open('critical_data.txt', 'w') as file:
file.write('Important data that must not be lost')
file.flush() # push Python's buffer to the OS
os.fsync(file.fileno()) # force the OS to write it physically to disk
flush() only moves data from Python’s internal buffer to the operating system’s buffer — it doesn’t guarantee the data has physically hit the disk platter or SSD cells yet. os.fsync() goes one level deeper and asks the OS to actually commit its buffers to physical storage. I reserve this combination for genuinely critical writes, since fsync() is noticeably slower and calling it constantly defeats the performance benefits of buffering in the first place.
Writing Binary Data
data = bytes([72, 101, 108, 108, 111]) # "Hello" as raw bytes
with open('output.bin', 'wb') as file:
file.write(data)
Trying to write a str in binary mode, or bytes in text mode, raises a TypeError immediately — Python is strict about this distinction, which I’ve come to appreciate since it catches encoding mistakes early rather than letting them corrupt a file silently.
try:
with open('output.txt', 'w') as file:
file.write(b'raw bytes') # TypeError: write() argument must be str, not bytes
except TypeError as e:
print(f"Error: {e}")
Storing Structured Data: CSV
import csv
employees = [
{'name': 'Alice', 'department': 'Engineering', 'salary': 95000},
{'name': 'Bob', 'department': 'Sales', 'salary': 72000},
]
with open('employees.csv', 'w', newline='', encoding='utf-8') as file:
writer = csv.DictWriter(file, fieldnames=['name', 'department', 'salary'])
writer.writeheader()
writer.writerows(employees)
The newline='' argument is one I forgot constantly early on, and it caused an extra blank line between every row on Windows. It’s needed because the csv module handles its own line-ending logic internally, and letting Python’s normal text-mode newline translation interfere with it produces doubled line endings on some platforms.
Storing Structured Data: JSON
import json
data = {'users': [{'name': 'Alice', 'active': True}, {'name': 'Bob', 'active': False}]}
with open('data.json', 'w', encoding='utf-8') as file:
json.dump(data, file, indent=2)
For anything I need to read back into Python later with full type fidelity (dicts, lists, numbers, booleans), JSON is almost always my default choice over raw text, since I don’t have to write my own parsing logic to reconstruct the data.
Atomic Writes: Preventing Corrupted Files
A failure mode I hit in production once: a script crashed halfway through writing a config file, leaving behind a truncated, invalid file that broke the application on its next startup. The fix is the atomic-write pattern — write to a temporary file first, then rename it into place, since file renames on most filesystems are atomic operations at the OS level.
import os
import tempfile
def atomic_write(filepath, content):
dir_name = os.path.dirname(filepath) or '.'
fd, temp_path = tempfile.mkstemp(dir=dir_name)
try:
with os.fdopen(fd, 'w') as temp_file:
temp_file.write(content)
os.replace(temp_path, filepath) # atomic on POSIX and Windows
except Exception:
os.remove(temp_path)
raise
atomic_write('config.json', '{"setting": "value"}')
If the process crashes during the write, the original config.json is untouched — the failure only affects the temporary file, which is either fully written and renamed into place, or never renamed at all. There’s no in-between corrupted state visible to anything reading config.json.
Error Handling When Writing
try:
with open('/protected/output.txt', 'w') as file:
file.write('data')
except PermissionError:
print("No write permission for this location.")
except OSError as e:
if e.errno == 28: # ENOSPC
print("Disk is full.")
else:
print(f"OS error occurred: {e}")
Disk-full errors specifically are ones I now handle explicitly in any long-running script that writes substantial data, since a script that silently swallows this error can appear to succeed while actually losing data.
Performance Considerations
- Batch writes (building a string/list and writing once) generally outperform many small individual
write()calls due to reduced Python-level call overhead. - For massive datasets, writing in reasonably sized chunks (rather than one enormous string, or thousands of tiny writes) balances memory usage against call overhead.
os.fsync()is expensive — reserve it for writes where durability genuinely matters more than speed, not for routine logging.- When writing large CSVs or structured exports, libraries like
pandas(to_csv()) orcsv.writerwith buffered file objects are implemented efficiently in C and usually outperform manual string concatenation loops.
import pandas as pd
df = pd.DataFrame({'id': range(100000), 'value': range(100000)})
df.to_csv('large_output.csv', index=False)
Common Mistakes I’ve Made
- Opening a file in
'w'mode when I meant'a', silently destroying existing content. - Forgetting
newline=''when writing CSV files, producing extra blank lines on Windows. - Not flushing/fsyncing critical data, then losing recent writes after an unexpected crash or power loss.
- Writing binary data in text mode (or vice versa), triggering
TypeErroror, worse, silent encoding corruption. - Assuming a partially written file is safe to read, when a concurrent reader could see a truncated, invalid file mid-write — this is exactly what the atomic-write pattern solves.
Real-World Use Cases
- Application logging — appending timestamped entries to a log file, often with line buffering for durability.
- Data export pipelines — writing processed data out as CSV or JSON for downstream consumption.
- Configuration persistence — saving application settings that need to survive restarts.
- Report generation — writing formatted text or structured output files for end users.
- Caching computed results to disk to avoid expensive recomputation on subsequent runs.
FAQs
What’s the difference between flush() and close()? flush() pushes buffered data to the OS without closing the file, so you can continue writing. close() flushes and then releases the file handle entirely — after close(), no further writes are possible on that object.
Why did my file end up with an extra blank line between every row? Almost always a missing newline='' argument when writing CSV on Windows, where the platform’s automatic newline translation combines with the csv module’s own line endings.
Is it safe to write to a file that another process is reading? It’s risky — readers may see a partially written or truncated file. The atomic-write pattern (write to a temp file, then rename) avoids this by ensuring readers only ever see either the old, complete file or the new, complete file.
How do I guarantee data isn’t lost if my script crashes? Use file.flush() followed by os.fsync(file.fileno()) for critical writes, understanding that this comes with a real performance cost, so use it selectively rather than everywhere.
What’s the fastest way to write a huge amount of data to a file? Batch your writes — build larger strings or use buffered writers — rather than calling write() repeatedly with tiny pieces of data, and consider specialized libraries like pandas for large structured datasets.
Summary
Storing data in a file in Python is deceptively simple at the surface level, but writing it reliably — without silent truncation, corrupted encodings, doubled line endings, or lost data on crash — depends on understanding buffering, choosing the correct write mode deliberately, and reaching for patterns like atomic writes when durability actually matters. These aren’t edge cases I invented for this guide; they’re mistakes I made in real scripts, and fixing them changed how carefully I think about every file-writing operation I write now.
References
- Python Official Documentation: Reading and Writing Files
- Python Official Documentation: Built-in Functions — open()
- Python Official Documentation: os.fsync() and low-level file operations
- Python Official Documentation: csv — CSV File Reading and Writing
- Python Official Documentation: tempfile — Generate temporary files and directories