openpyxl vs xlsxwriter vs pandas.ExcelWriter
The three names look like competing libraries, but they sit at different layers. pandas.ExcelWriter is not an engine at all — it's a thin wrapper that delegates to one. The real engines are openpyxl and xlsxwriter, and they have opposite strengths: openpyxl can read and write existing files and preserves styles, while xlsxwriter is write-only but faster and richer at formatting and charts. This guide, part of Writing DataFrames to Excel with Pandas, clears up the relationship, gives you a decision table, and writes the same DataFrame three ways.
Prerequisites
Install pandas and both engines:
pip install pandas openpyxl xlsxwriter
How the pieces relate
df.to_excel(path) and pd.ExcelWriter(path) both produce a file by handing your data to an engine. When you don't name one, pandas picks a default (openpyxl for .xlsx). You choose explicitly with engine=:
- openpyxl — read + write. Opens an existing
.xlsx, edits cells in place, appends sheets, and keeps the styles already in the file. This is the only engine that can modify a workbook you already have. - xlsxwriter — write only. It builds a brand-new file fast and exposes the richest formatting, conditional formats, and native charts. It cannot open or read an existing workbook.
- pandas.ExcelWriter — the wrapper. It exposes
mode="a"(append) and abook/sheetshandle so you can reach the underlying engine object for formatting.
Decision table
| Need | openpyxl | xlsxwriter | plain to_excel |
|---|---|---|---|
| Read an existing file | Yes | No | n/a (write-only) |
| Edit a file in place | Yes | No | No |
Append a sheet (mode="a") | Yes | No | Uses openpyxl |
| Native charts | Limited | Yes (strong) | Whatever engine you pass |
| Conditional formatting | Limited | Yes | Via engine |
| Raw write speed on large files | Good | Fastest | Matches the engine |
| Preserve styles in source file | Yes | n/a | Only via openpyxl |
| Quick one-off dump | Overkill | Overkill | Best fit |
Write 1: plain to_excel for a quick dump
No engine argument, no formatting — the fastest way to get a DataFrame onto disk:
import pandas as pd
df = pd.DataFrame({
"Region": ["North", "South", "West"],
"Revenue": [12000, 9800, 15400],
})
df.to_excel("dump.xlsx", index=False) # defaults to openpyxl
print("wrote dump.xlsx")
Write 2: xlsxwriter with a cell format
Reach the workbook and worksheet objects through the ExcelWriter, then add a currency format. xlsxwriter shines here because formatting is first-class:
import pandas as pd
df = pd.DataFrame({
"Region": ["North", "South", "West"],
"Revenue": [12000, 9800, 15400],
})
with pd.ExcelWriter("styled.xlsx", engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="Report", index=False)
workbook = writer.book
worksheet = writer.sheets["Report"]
money = workbook.add_format({"num_format": "$#,##0", "bold": True})
worksheet.set_column("B:B", 14, money) # format the Revenue column
print("wrote styled.xlsx")
Write 3: openpyxl direct, to edit an existing file
This is where openpyxl is the only option: load a file that already exists, change a cell, and save. xlsxwriter physically cannot do this because it never reads:
import pandas as pd
from openpyxl import load_workbook
# Start from a file written earlier
pd.DataFrame({"Region": ["North"], "Revenue": [12000]}).to_excel(
"ledger.xlsx", index=False
)
wb = load_workbook("ledger.xlsx") # read the existing workbook
ws = wb.active
ws["B2"].value = 13500 # edit a cell in place
ws["C1"] = "Adjusted" # add a column header
wb.save("ledger.xlsx")
print("updated:", load_workbook("ledger.xlsx").active["B2"].value)
Appending a sheet needs openpyxl
mode="a" opens the existing file to add to it, so it requires the read-capable engine. Passing engine="xlsxwriter" with mode="a" raises a ValueError:
import pandas as pd
pd.DataFrame({"Region": ["North"], "Revenue": [12000]}).to_excel(
"book.xlsx", sheet_name="Jan", index=False
)
with pd.ExcelWriter("book.xlsx", engine="openpyxl", mode="a") as writer:
pd.DataFrame({"Region": ["South"], "Revenue": [9800]}).to_excel(
writer, sheet_name="Feb", index=False
)
print(pd.ExcelFile("book.xlsx").sheet_names)
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
ValueError: Append mode is not supported with xlsxwriter | xlsxwriter can't open existing files | Use engine="openpyxl" for mode="a" |
FileNotFoundError/empty file when "editing" | Tried to load an existing file with xlsxwriter | Use load_workbook (openpyxl); xlsxwriter only creates new files |
| Styles vanish after a pandas round-trip | Reading to a DataFrame keeps values, not formatting | Edit with openpyxl directly instead of read_excel then to_excel |
| Chart or conditional format ignored | Tried to add it through the wrong engine | Build new styled reports with xlsxwriter |
The styling-loss trap is the one that bites teams: pd.read_excel() returns only the data, so a read_excel → to_excel cycle silently drops every fill, border, and number format the source had. To keep formatting, never pass through a DataFrame — style the cells with openpyxl and modify them in place.
Performance and scale
For large write-only jobs, xlsxwriter is the fastest and supports constant_memory mode to stream rows without holding the whole sheet in RAM. openpyxl is competitive and adds a write_only=True mode for the same reason, but it pays a cost to preserve existing content. For a quick dump under a few thousand rows, the engine choice is irrelevant — use plain to_excel.
Picking an engine from the job
The decision is usually made by two questions: does the file already exist, and does the output need rich formatting?
The one combination worth remembering is append mode: pd.ExcelWriter(path, mode="a") requires
openpyxl, because appending means reading the existing workbook first. Passing engine="xlsxwriter"
with mode="a" raises immediately, which is the library being helpful rather than obstructive.
Formatting through the writer
Both engines expose their native objects through the writer, and the two APIs are different enough that code cannot be swapped between them:
import pandas as pd
with pd.ExcelWriter("styled.xlsx", engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="Report", index=False)
workbook, sheet = writer.book, writer.sheets["Report"]
money = workbook.add_format({"num_format": "#,##0.00"})
sheet.set_column("C:C", 14, money) # xlsxwriter: format applies to the column
xlsxwriter attaches formats to columns and rows, which is why it is fast; openpyxl attaches them to cells, which is why it can edit an existing file. Choosing an engine therefore commits the formatting code as well as the write, and mixing the two in one project is the most common source of "why does this work on the other report?".
Pick once, per project
Because the two engines' formatting APIs are incompatible, mixing them across a codebase means every report needs its own styling helper. Choosing one as the default — openpyxl where files are edited, xlsxwriter where they are generated — and writing the finishing function against it is what makes styling reusable rather than something each report reinvents.
The wrapper is not the engine
Remembering that pandas.ExcelWriter delegates rather than implements resolves most confusion in this area: mode="a" works only because openpyxl can read, chart APIs differ because they belong to the engines, and an unsupported combination raises from the engine rather than from pandas. Reading the error message with that layering in mind usually points straight at the fix.
Presentation comes after the data
Any write replaces what it covers, so formatting, filters, images and charts belong in a single
finishing pass that runs after the last value has been written. Splitting the job that way — build
the frame, write it, then decorate the finished sheet — is what stops a style disappearing the month
someone adds a to_excel call in the middle. It also gives a report one obvious place to change when
the house style moves, instead of a dozen scattered blocks that have to be found first.
Let the job choose the engine
The engine question answers itself once the job is described: editing an existing workbook needs
openpyxl, generating a large formatted file suits xlsxwriter, and a quick dump needs no decision at
all. Writing that reasoning into a comment beside the ExcelWriter call saves the next reader from
re-deriving it — and saves them from switching engines for a formatting feature and discovering that
append mode has stopped working.
Frequently asked questions
Is pandas.ExcelWriter an engine?
No. It's a context-manager wrapper that delegates to openpyxl or xlsxwriter. You pick the real engine with engine=.
Which engine does to_excel use by default?
For .xlsx, pandas defaults to openpyxl when it's installed. Name engine="xlsxwriter" explicitly when you want xlsxwriter's formatting.
Can xlsxwriter edit an existing workbook? Never. It is write-only and creates a fresh file each run. Any in-place edit or append must go through openpyxl.
How do I add a chart to a pandas export?
Write with engine="xlsxwriter", grab writer.book and writer.sheets[...], then build the chart with xlsxwriter's chart API.
Conclusion
Think in layers: pandas.ExcelWriter is the wrapper, openpyxl and xlsxwriter are the engines. Reach for openpyxl when you must read, edit in place, or append to a file that already exists; reach for xlsxwriter to build new, heavily styled reports and charts from scratch; and use plain to_excel for a quick dump where formatting doesn't matter. The single rule that prevents most surprises: a pandas round-trip keeps data but loses styling, so edit existing files with openpyxl directly.
Where to go next
- Writing DataFrames to Excel with Pandas — the parent guide, with the full export workflow and multi-sheet writes.
- Write a Pandas DataFrame to Excel Without the Index — drop the row-number column cleanly on any of these engines.
- Append Data to an Existing Excel Sheet with openpyxl — the read-capable engine's append pattern in depth.
- Creating Charts in Excel with openpyxl — build native charts once your data is on the sheet.
- Formatting and Charting Excel Reports with Python — take styling and reporting further.