Difference Between write() and writelines() in Python (Practical Guide)

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 None in modern CPython. Many references say it returns None, 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 \n right after write().
  • Modern: build a list with \n and use writelines() 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, but writelines() 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.

Dimension

write()

writelines()

— Input type

One string

Iterable of strings Newline handling

Manual

Manual Readability for single line

High

Low Readability for many lines

Medium

High Memory usage

Low

Low or high depending on iterable Error granularity

Per call

Per iterable item (but less explicit) Best for streaming

Yes

Yes, with generator Best for big batch

OK

Better

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 requires os.fsync() in critical systems).
  • writelines() does not flush automatically, just like write() 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 \n in 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.

Scroll to Top