The last time I debugged a "missing lines" bug in a data export job, the root cause was a single function call: I had used write() when the caller handed me a list of lines. The code ran, tests passed, and the file looked fine at first glance—until downstream parsing failed because everything landed on one line. That is the kind of subtle mistake that happens when the distinction between write() and writelines() feels obvious but is not internalized.
If you write files for a living—logs, reports, configs, CSVs, build artifacts—you need to know the difference with absolute clarity. I will show you how each function behaves, where developers get tripped up, and how I choose between them in modern Python workflows. You will get runnable examples, practical patterns, and a performance-and-correctness lens that holds up in 2026 codebases.
The mental model I use: single string vs sequence of strings
I treat write() as "one string, write it exactly as given," and writelines() as "many strings, write them in order, unchanged." That model seems simple, but the unchanged part is the trap: neither function adds newline characters for you. If you want line breaks, you must supply them yourself.
write()expects a string (or any object that implements the string protocol; more on that later) and writes it as-is.writelines()expects an iterable of strings and writes each item as-is, back-to-back.
A good analogy is packing boxes. write() is putting a single item into a box. writelines() is packing a sequence of items, one after another, without any separators unless you include them.
write() in practice: absolute control, zero safety rails
When I need precise control—like writing a header, a JSON blob, or a single log entry—I default to write() because it is explicit and predictable. Here is a full, runnable example that writes three employee names, each on its own line:
Python example (runnable):
file = open("Employees.txt", "w", encoding="utf-8")
for _ in range(3):
name = input("Enter the name of the employee: ")
file.write(name)
file.write("\n") # I must add the newline myself
file.close()
print("Data is written into the file.")
Key behaviors:
- If you forget the newline, the names will be concatenated on one line.
- The function returns the number of characters written, not
Nonein modern CPython. Many references say it returnsNone, but you will often see an integer if you inspect it. That integer is a fast sanity check in debugging. - Writing non-string values (like integers) raises
TypeError. You must convert explicitly.
Python example (safe string conversion):
file = open("metrics.txt", "w", encoding="utf-8")
count = 42
file.write(str(count) + "\n")
file.close()
In my day-to-day work, I often pair write() with f-strings for readability:
Python example (structured line):
file = open("audit.log", "a", encoding="utf-8")
user = "Rhea"
action = "exported"
file.write(f"{user} {action} data at 2026-02-17T09:12:00Z\n")
file.close()
writelines() in practice: efficient for many lines, still manual about newlines
I reach for writelines() when I already have a list or generator of lines. It is particularly handy when lines are produced earlier in the pipeline, and I want to dump them in one call.
Python example (runnable):
file = open("Employees.txt", "w", encoding="utf-8")
lines = []
for _ in range(3):
name = input("Enter the name of the employee: ")
lines.append(name + "\n")
file.writelines(lines)
file.close()
print("Data is written into the file.")
Notice that I put \n into each element. If I do not, I will get a single long line.
The function accepts any iterable of strings, not only lists. That means you can stream data without keeping it all in memory:
Python example (generator):
def build_lines():
for i in range(1, 4):
yield f"Employee {i}\n"
with open("Employees.txt", "w", encoding="utf-8") as file:
file.writelines(build_lines())
This is a nice pattern when you are generating large outputs and want to keep memory stable.
The difference that actually matters: how you manage separators
The practical difference is not just "string vs list," it is whether you remember to include separators and line endings. Both functions are literal. Neither adds \n or os.linesep automatically.
Here is a quick contrast that I use when mentoring juniors:
write()writes one string; you manage all separators.writelines()writes many strings; you still manage all separators, but you do it earlier.
That changes where you put the logic. With write(), you often add separators inline as you write. With writelines(), you typically build lines with separators first, then dump them all at once.
A modern decision table I use in code reviews
When I review code, I want developers to choose for clarity, not habit. This table reflects how I guide the choice in current Python codebases.
Traditional vs Modern guidance:
- Traditional:
write()is for a single line;writelines()is for multiple lines. - Modern: choose based on where line boundaries are defined (at write-time vs build-time).
- Traditional: add
\nright afterwrite(). - Modern: build a list with
\nand usewritelines()for bulk output or streaming.
- Traditional: ignore return value.
- Modern: use the return value from
write()for quick diagnostics in hot paths.
- Traditional: use
write()in loops. - Modern:
write()in loops is fine, butwritelines()can reduce call overhead when you already have the lines.
If you force yourself to decide where line boundaries should live, you will always pick the right function.
Real-world scenarios and the choice I recommend
1) Logging in a long-running service
I prefer write() because I am writing one log entry at a time and I want each call to reflect one event. That keeps the code explicit and makes it easier to add flush behavior.
Python example:
with open("service.log", "a", encoding="utf-8") as log:
log.write("Starting cache rebuild\n")
log.flush() # ensures the line is persisted immediately
2) Exporting a report or CSV
If I already have rows in memory, writelines() is cleaner. I generate lines with \n and dump them.
Python example:
rows = [
"name,team,score\n",
"Rhea,Alpha,91\n",
"Rahul,Beta,88\n",
]
with open("scores.csv", "w", encoding="utf-8") as file:
file.writelines(rows)
If rows are large, I use a generator and writelines() to avoid a huge list.
3) Building a templated config file
I usually mix both. I write() the fixed header, then writelines() a generated block.
Python example:
header = "# Auto-generated config\n# Do not edit by hand\n"
def settings_block():
for key, value in [("timeout", 30), ("retries", 3)]:
yield f"{key}={value}\n"
with open("app.conf", "w", encoding="utf-8") as file:
file.write(header)
file.writelines(settings_block())
This gives me clean structure and a minimal number of file calls.
Common mistakes I see (and how I avoid them)
Mistake 1: Assuming writelines() adds newlines
It does not. I have watched production scripts produce single-line CSV files because of this.
I prevent it by defining a helper that always appends \n:
Python example:
def to_lines(items):
return [f"{item}\n" for item in items]
with open("names.txt", "w", encoding="utf-8") as file:
file.writelines(to_lines(["Aditya", "Aditi", "Anil"]))
Mistake 2: Passing non-strings to writelines()
writelines() requires each element to be a string. Integers or bytes will raise a TypeError.
Fix:
Python example:
values = [1, 2, 3]
lines = [str(v) + "\n" for v in values]
with open("values.txt", "w", encoding="utf-8") as file:
file.writelines(lines)
Mistake 3: Using write() in a tight loop without buffering
Repeated small writes can be fine, but in high-throughput jobs, it can be slower. When I see loops writing thousands of times, I ask: "Can you batch?"
Fix:
Python example:
lines = [f"row {i}\n" for i in range(10000)]
with open("rows.txt", "w", encoding="utf-8") as file:
file.writelines(lines)
Mistake 4: Forgetting encoding
In 2026, I treat encoding="utf-8" as non-negotiable. It prevents subtle cross-platform issues.
Performance considerations you can actually act on
The performance difference between write() and writelines() is usually about call overhead and buffering. If you call write() 100,000 times, you pay overhead 100,000 times. If you call writelines() once with the same amount of data, you pay overhead once.
In typical modern workloads:
- Thousands of
write()calls can add tens of milliseconds of overhead in a hot path. - A single
writelines()call can be noticeably faster when you already have the lines ready. - The filesystem and OS buffer dominate at larger sizes, so the difference shrinks if you are writing megabytes.
My rule of thumb:
- If you naturally have one line at a time, use
write()and keep the code clear. - If you naturally have a list or generator of lines, use
writelines()to avoid loop overhead.
How I handle line endings across platforms
Both write() and writelines() are agnostic about line endings. You decide whether to use \n or \r\n. In 2026, I default to \n and let tooling handle the rest.
If you need OS-specific endings, use os.linesep:
Python example:
import os
lines = ["first" + os.linesep, "second" + os.linesep]
with open("platform.txt", "w", encoding="utf-8") as file:
file.writelines(lines)
Note: many modern tools accept \n on Windows without issues, so I prefer the simplicity unless a strict legacy consumer demands \r\n.
write() and writelines() in buffered I/O and context managers
The biggest quality-of-life shift I have seen in 2026 code is consistent use of with open(...) as f:. Both functions behave the same, but the context manager guarantees the file closes cleanly, even if an error occurs.
Python example:
with open("notes.txt", "w", encoding="utf-8") as file:
file.write("Meeting notes\n")
file.writelines(["- Budget approved\n", "- Launch on Friday\n"])
You will also see buffering handled in layers like io.BufferedWriter or pathlib.Path.write_text. Those can be cleaner, but when you are on a raw file object, write() and writelines() remain the primitives.
A practical checklist for choosing the right function
When I am coding quickly, I ask myself these questions:
- Do I have one string or many? If many,
writelines()wins. - Do I want to control the separator at the moment of writing? If yes,
write()keeps it obvious. - Do I already have a generator of lines? If yes,
writelines()is clean and memory friendly. - Is this a performance-sensitive loop? If yes, batch and use
writelines().
If two options are equally clean, I choose the one that makes the newline logic most visible to the next person reading the code.
Edge cases that matter in production
Writing bytes vs strings
These functions expect strings for text mode and bytes for binary mode. If you open a file in binary mode ("wb"), you must provide bytes.
Python example:
data = b"binary payload\n"
with open("payload.bin", "wb") as file:
file.write(data)
writelines() behaves similarly with bytes in binary mode, but every element must be bytes.
Partial writes and error handling
write() returns the number of characters written. In rare cases (like disk full or interrupted I/O), you might not get the entire string. For most local filesystem usage, partial writes are uncommon, but in resilient systems I check return values when it is critical.
Python example:
with open("critical.txt", "w", encoding="utf-8") as file:
expected = "IMPORTANT\n"
written = file.write(expected)
if written != len(expected):
raise IOError("Partial write detected")
Large outputs and memory pressure
writelines() can be memory efficient if you use a generator. It can be memory heavy if you build a huge list first. This is why I often use generators for large exports:
Python example:
def export_rows(count):
for i in range(count):
yield f"row-{i}\n"
with open("big.txt", "w", encoding="utf-8") as file:
file.writelines(exportrows(1000_000))
A quick, realistic comparison example
Here is the same output produced two different ways. You will see that the content is identical, but the code emphasizes different clarity points.
Python example (write in loop):
names = ["Rhea", "Rohan", "Rahul"]
with open("team.txt", "w", encoding="utf-8") as file:
for name in names:
file.write(name + "\n")
Python example (writelines with prebuilt list):
names = ["Rhea", "Rohan", "Rahul"]
lines = [name + "\n" for name in names]
with open("team.txt", "w", encoding="utf-8") as file:
file.writelines(lines)
If I am already in a loop, I usually pick the write() version because it reads naturally. If I am transforming data into lines elsewhere, I prefer writelines() for the batch write.
Under the hood: what these methods actually do
I think it helps to understand the file object as a thin interface over a buffered I/O layer. Both write() and writelines() end up writing bytes to an internal buffer, then eventually flushing that buffer to the OS.
What matters for your decision:
- Both methods are synchronous and blocking in the default file object. They do not return until the data is handed to the buffer (not necessarily to disk).
writelines()is not magical. Internally, it loops over the iterable and calls the same low-level write primitive for each item. The difference is that you are delegating the loop to the file object instead of managing it in Python.- Because
writelines()still loops, you should not expect orders-of-magnitude speedups. The win is reducing Python-level overhead and keeping your call site concise.
If you want true batching, you can also join lines yourself and make a single write() call:
Python example:
lines = ["a\n", "b\n", "c\n"]
with open("letters.txt", "w", encoding="utf-8") as f:
f.write("".join(lines))
This is sometimes faster than writelines() because it creates one big string and performs one write call. The trade-off is memory usage, and the fact that you are building an intermediate string that could be huge.
When I use write() even with many lines
There are cases where I still pick write() even if I am writing many lines. The two most common are:
1) I want a single line and a flush for each event. Logs and audit files fall here. I want the file to reflect reality immediately.
2) I am calculating each line inline and do not want to build a list just for the sake of using writelines().
Python example (write with explicit flush policy):
with open("events.log", "a", encoding="utf-8") as log:
for event in events:
log.write(format_event(event) + "\n")
if event.severity == "ERROR":
log.flush() # make critical errors visible immediately
This is not about speed, it is about semantics and clarity. Each loop iteration is one event, one write, optionally one flush. That is easy to reason about.
When I avoid writelines() on purpose
I also avoid writelines() in a few practical cases:
- When each line may fail and I want precise error handling per line. A loop with
write()allows try/except per item. - When I want to short-circuit and stop writing if a validation check fails mid-stream.
- When I need to interleave writing with other I/O calls or progress reporting.
Python example (per-line validation):
with open("filtered.txt", "w", encoding="utf-8") as f:
for line in lines:
if not line.endswith("\n"):
raise ValueError("line missing newline")
f.write(line)
You can still do this with writelines() by pre-validating the iterable, but then you are doing two passes. In this situation, the loop is clearer.
Alternative approaches that sometimes beat both
Most of the time, write() and writelines() are the right primitives. But there are cases where I reach for other tools.
1) print(..., file=f) for quick scripts
If I am writing a small script and want line breaks by default, print() is my friend:
Python example:
with open("simple.log", "w", encoding="utf-8") as f:
print("start", file=f)
print("middle", file=f)
print("end", file=f)
print() adds a newline by default and handles sep and end options. I still do not use it in performance-sensitive code, but it is great for quick tools.
2) pathlib.Path.write_text for single payloads
If I already have the entire content as one big string, Path.write_text() is clean:
Python example:
from pathlib import Path
content = "hello\nworld\n"
Path("message.txt").write_text(content, encoding="utf-8")
This is effectively a single write() call under the hood.
3) The csv module for true CSVs
If you are writing CSV data, I recommend using csv.writer rather than manual string concatenation. It handles quoting and escaping for you, which is where most CSV bugs live.
Python example:
import csv
rows = [
["name", "team", "score"], ["Rhea", "Alpha", 91], ["Rahul", "Beta", 88],]
with open("scores.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerows(rows)
This is a perfect example where the data shape should determine the API, not write() vs writelines().
The newline trap: a deeper explanation
When people say "writelines does not add newlines," the hidden issue is that many datasets already contain newline characters in their values, and you can accidentally double-insert or double-strip them.
I deal with this by making a clear decision early:
- Either I guarantee every element ends with exactly one newline and write them as-is.
- Or I store raw data without trailing newlines and add them at the point of writing.
The worst case is a mix. A few elements already end with \n, others do not, and the output becomes inconsistent. If you cannot guarantee consistency, normalize it:
Python example (normalize line endings):
def normalize_line(line):
return line.rstrip("\r\n") + "\n"
with open("normalized.txt", "w", encoding="utf-8") as f:
f.writelines(normalizeline(x) for x in rawlines)
Now you always get exactly one line ending, and your downstream parsing stays predictable.
Memory, streaming, and generators: my practical guidance
If you are working with large data, the generator pattern with writelines() is my favorite. It keeps memory stable and reduces the risk of hidden spikes.
However, there are two subtle issues to keep in mind:
1) Generators can hide exceptions until the file write is already underway. That is fine, but you should be aware that the file may contain partial output if a later item fails.
2) If your generator depends on external state (like reading a database cursor), errors can occur mid-write. If that matters, consider writing to a temp file, then renaming on success.
Python example (safe temp file strategy):
import os
import tempfile
def generate_lines():
for i in range(5):
yield f"row {i}\n"
dir_path = "."
fd, temppath = tempfile.mkstemp(dir=dirpath, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.writelines(generate_lines())
os.replace(temp_path, "final.txt")
except Exception:
try:
os.remove(temp_path)
except OSError:
pass
raise
This pattern keeps your output atomic and is invaluable when partial files would be dangerous.
A deeper comparison table: clarity, safety, and speed
Here is a compact table I use in internal docs. It forces you to reason beyond the obvious.
write()
writelines() —
One string
Manual
High
Medium
Low
Per call
Yes
OK
The takeaway is not that one is always better. It is that they optimize for different code shapes.
Testing and validation patterns I actually use
When files are part of a pipeline, I do not trust them unless I validate the output. I often add lightweight checks right after writing.
Validate line count
Python example:
lines = ["a\n", "b\n", "c\n"]
with open("letters.txt", "w", encoding="utf-8") as f:
f.writelines(lines)
with open("letters.txt", "r", encoding="utf-8") as f:
count = sum(1 for _ in f)
if count != len(lines):
raise AssertionError("line count mismatch")
Validate last newline policy
Some tools require a trailing newline. If that matters, I check it explicitly.
Python example:
with open("output.txt", "w", encoding="utf-8") as f:
f.write("hello\n")
with open("output.txt", "rb") as f:
f.seek(-1, 2)
if f.read(1) != b"\n":
raise AssertionError("missing trailing newline")
These checks are small but catch big headaches later.
The subtle interplay with buffering and flush
Both methods write into a buffer. That means you can see unexpected behavior if you expect the data to be physically on disk immediately.
I keep these rules in mind:
- Closing the file flushes it.
flush()forces the buffer to be written out, but it does not guarantee the data is persisted to disk (that requiresos.fsync()in critical systems).writelines()does not flush automatically, just likewrite()does not.
Python example (flush for critical logs):
with open("critical.log", "a", encoding="utf-8") as log:
log.write("CRITICAL: payment failed\n")
log.flush()
If you are dealing with compliance or audits, you might also need os.fsync() after flush. I only do that when the requirements demand it, because it can be expensive.
Debugging tips for common production failures
When output looks wrong, I walk through this checklist:
1) Are line endings present? If not, check the data you passed to write() or writelines().
2) Are there hidden carriage returns? If yes, normalize using rstrip("\r\n") + "\n".
3) Is the file opened in the correct mode? Mixing "wb" and strings will throw errors, mixing "w" and bytes will throw errors.
4) Is encoding specified? If not, the default encoding can vary by OS and environment.
5) Are you writing to the right file path? Relative paths can surprise you in different execution contexts.
These five checks solve 90 percent of the "why is my file weird" questions I see in code reviews.
Practical do-and-do-not guidance
I like to keep these short, so I can paste them into a team README.
Do:
- Do use
write()for single strings and for per-event logs. - Do use
writelines()for lists or generators of lines. - Do add newlines yourself and keep the policy consistent.
- Do set
encoding="utf-8"explicitly.
Do not:
- Do not assume
writelines()adds separators. - Do not pass non-strings to
writelines()in text mode. - Do not build giant lists of lines when a generator will do.
- Do not ignore errors when output is critical.
How I teach this to new Python developers
If I have five minutes with a new developer, I say this:
"write() is for one string. writelines() is for many strings. Neither adds newlines. Choose based on where you want to define your line boundaries."
Then I show them this tiny example and ask them to predict the output:
Python example:
with open("demo.txt", "w", encoding="utf-8") as f:
f.writelines(["a", "b", "c"])
Most guess it will be three lines. It is a single line. That moment of surprise locks in the rule forever.
A longer, realistic example: exporting a report
Here is a complete example that shows a small but realistic report export. It uses a generator so the output is streamed, and it mixes write() and writelines() for clarity.
Python example:
from datetime import datetime
def report_header():
ts = datetime.utcnow().isoformat() + "Z"
return f"Report generated at {ts}\n\n"
def report_rows(records):
yield "id,name,score\n"
for r in records:
yield f"{r[‘id‘]},{r[‘name‘]},{r[‘score‘]}\n"
records = [
{"id": 1, "name": "Rhea", "score": 91},
{"id": 2, "name": "Rahul", "score": 88},
]
with open("report.csv", "w", encoding="utf-8") as f:
f.write(report_header())
f.writelines(report_rows(records))
Here, the header is naturally a single string, while the rows are naturally an iterable. The choice makes the code read like the structure of the file itself.
How I document this in a codebase
When I am writing team guidelines, I keep it short and practical:
- Use
write()for single strings or when line breaks are defined at write-time. - Use
writelines()for lists or generators of strings to reduce call overhead. - Always include
\nin your data when you want line breaks. - Always set
encoding="utf-8"for text files unless you have a clear reason not to.
That is it. It is easy to remember and prevents most mistakes.
Key takeaways and what I recommend you do next
If you remember one thing, make it this: both write() and writelines() are literal. They do exactly what you tell them and nothing more. That gives you precision, but it also makes line endings your responsibility. I recommend you choose based on where line boundaries live in your code: at the time you write, or earlier when you build the data.
If you write in a loop and want each iteration to map to one line, stick with write() and make the \n obvious. If you already have a list or generator of lines, writelines() is cleaner and usually faster, especially when the loop overhead starts to show. And in all cases, lock in encoding="utf-8"—you will save yourself hours of platform debugging later.
Your next step is simple: scan the last file-writing code you touched. If it is using writelines(), check that every element already has its newline. If it is using write() in a huge loop, consider batching. These are small tweaks, but they eliminate real bugs in production pipelines. I have been burned by them more than once, and I do not want you to be.
If you want to go deeper, I suggest you build a tiny test harness that writes the same data using write(), writelines(), and "".join(...) + write(), then measure the behavior you actually care about: correctness, memory usage, and runtime on your real datasets. The best choice in Python is the one that keeps your output correct and your intent obvious to future readers. That is the standard I aim for, and the standard I recommend you adopt.


