Choosing a Python Excel Library
Every Python Excel job starts with the same question: which library. The ecosystem has five real answers — openpyxl, xlsxwriter, pandas, Polars and xlwings — plus a handful of engines that sit underneath them, and the honest summary is that they are not competitors so much as tools for different halves of the job. This guide, part of Getting Started with Python Excel Automation, maps the landscape by the decision that actually drives it: what you need to do to the file.
The short version: pandas and Polars are for the data, openpyxl and xlsxwriter are for the file, and xlwings is for the application. Almost every production script uses two of them together — a frame library to shape the numbers and a file library to lay them out — and the mistakes people make are nearly always the result of asking one of them to do the other's job.
What each library is actually for
Read the table below as a set of jobs, not a ranking. openpyxl is the only one that can open an existing workbook, change three cells and save it with everything else intact — that single capability is why it stays installed even on projects that write with something faster. xlsxwriter is write-only by design, and that constraint is what lets it stream a million rows in constant memory and expose the richest formatting API of the group. pandas and Polars do not touch Excel at all: they delegate to one of the others and spend their effort on the table in between.
The engines matter as much as the libraries. pandas.read_excel() is a thin front end over
whatever parser can handle the extension you gave it, so "pandas is slow at Excel" is usually a
statement about openpyxl. Swapping the engine — covered in
Speed Up pandas Excel Reads with the calamine Engine —
changes the number without changing a line of your own logic.
import pandas as pd
# Same call, three different parsers underneath.
default = pd.read_excel("sales.xlsx") # openpyxl
fast = pd.read_excel("sales.xlsx", engine="calamine") # python-calamine (Rust)
legacy = pd.read_excel("archive.xls", engine="xlrd") # xlrd 1.2.0 only
print(default.shape, fast.shape, legacy.shape)
Reading: get the data in, then forget the format
For reading, the ranking is stable and mostly about the parser. openpyxl walks the sheet XML in Python; calamine does the same work in Rust and hands back a ready table. On a wide export the difference is the largest single speed lever available in this ecosystem, and it costs one keyword argument.
The numbers move with the file, but the shape holds: a Rust parser is several times faster than the pure-Python one, and converting the sheet to a columnar format once makes every later read almost free. That last point is the one worth internalising — if the same workbook is read more than twice, converting it is cheaper than parsing it again, as Convert Excel Files to Parquet with Python shows.
import time
import pandas as pd
for engine in ("openpyxl", "calamine"):
start = time.perf_counter()
frame = pd.read_excel("big.xlsx", engine=engine)
print(f"{engine:>9}: {time.perf_counter() - start:5.2f}s {frame.shape}")
Reading is also where the defensive habits pay off. Whichever library you pick, name the sheet rather than trusting position, prune the columns you do not need, and pin the dtypes of anything that looks like an identifier — the reasoning is set out in Reading Excel Files with Pandas.
Writing: formatting versus volume
Writing splits cleanly. If the output is a table someone will open, sort and read, xlsxwriter gives
you the most control for the least code: number formats, conditional formats, charts, autofilters
and frozen panes are all first-class, and its constant_memory mode keeps a million-row export
inside a fixed footprint. If the output has to merge into a workbook that already exists — a
template with a logo, a summary tab, three years of history — openpyxl is the only option, because
xlsxwriter cannot open a file at all.
pandas sits on top of either. DataFrame.to_excel() with an ExcelWriter is the shortest path
from a frame to a formatted sheet, and the engine choice is yours:
import pandas as pd
frame = pd.DataFrame({"Region": ["North", "South"], "Revenue": [128400.0, 96150.5]})
with pd.ExcelWriter("report.xlsx", engine="xlsxwriter") as writer:
frame.to_excel(writer, sheet_name="Summary", index=False)
book, sheet = writer.book, writer.sheets["Summary"]
money = book.add_format({"num_format": "#,##0.00"})
sheet.set_column("B:B", 14, money)
sheet.freeze_panes(1, 0)
The same script with engine="openpyxl" writes the same values but reaches formatting through
openpyxl's style objects instead. Neither is more correct; the deciding question is whether the
file already exists.
Editing a workbook without breaking it
This is the capability that surprises people. Most of the ecosystem writes files; only openpyxl and
the application drivers edit them. And openpyxl's edit is not lossless — it rebuilds the parts of
the file it understands and drops what it does not, which historically has meant pivot table caches,
some chart types, VBA in files opened without keep_vba=True, and slicers.
from openpyxl import load_workbook
book = load_workbook("quarterly.xlsx") # add keep_vba=True for .xlsm
sheet = book["Summary"]
sheet["B2"] = 128400.0 # patch one number
book.save("quarterly-revised.xlsx") # never save over the original
Saving under a new name is not fussiness — it is the difference between a bad run costing you a minute and costing you the source file. When the workbook carries objects openpyxl cannot round-trip, the alternatives are to drive Excel itself (see below) or to rebuild the file from scratch, and Populate an Excel Template Without Losing Formatting walks through choosing between them.
Driving Excel itself, and when it is worth it
xlwings and pywin32 are a different category: they do not parse the file, they remote-control a running copy of Excel. That buys the things the format-level libraries cannot do — recalculating formulas with Excel's own engine, refreshing a Power Query connection, running an existing macro, exporting a range to PDF exactly as Excel would print it — at the cost of needing Excel installed, being effectively Windows- or macOS-only, and being an order of magnitude slower per operation.
The rule that holds up in practice: reach for an application driver only when the value you need comes from Excel, not from the data. Recalculated values, a native PDF export and a macro run all qualify. Writing 40,000 rows does not — that belongs to xlsxwriter, and pushing it through COM will take minutes instead of seconds. Automating Excel with xlwings Basics covers the friendly API, and Automating Excel with COM and pywin32 covers the raw interface underneath it.
Combining them in one pipeline
The realistic answer to "which library" is usually "two". A reporting job that reads a large export, reshapes it and ships a formatted workbook uses a fast reader, a frame library and a formatting writer, and each hands off cleanly to the next.
import pandas as pd
raw = pd.read_excel("export.xlsx", engine="calamine", dtype={"Account": "string"})
summary = (
raw.dropna(subset=["Revenue"])
.groupby("Region", as_index=False)["Revenue"].sum()
.sort_values("Revenue", ascending=False)
)
with pd.ExcelWriter("regional-summary.xlsx", engine="xlsxwriter") as writer:
summary.to_excel(writer, sheet_name="By region", index=False)
sheet = writer.sheets["By region"]
sheet.set_column("A:A", 18)
sheet.set_column("B:B", 16, writer.book.add_format({"num_format": "#,##0"}))
sheet.autofilter(0, 0, len(summary), 1)
Three libraries, twelve lines, and each one is doing the job it is best at. That is the pattern the rest of this section elaborates: pick the reader for speed, the frame library for the transform, and the writer for what the file has to look like when it lands.
Memory: the number that decides for you
Speed is negotiable; memory is not. A .xlsx file is a zip of XML, and every library that reads
one has to expand it. openpyxl's normal mode builds a Python object per cell, which is roughly
one to two kilobytes each once you count the object header, the style reference and the dictionary
that holds it — so a 500,000-cell sheet can cost well over a gigabyte before your own code sees a
single value. That is the number that turns "it works on my laptop" into a killed container at
06:00.
Each library has one lever that changes the shape of that curve. openpyxl has read_only=True,
which streams rows instead of materialising the grid, and write_only=True, which does the same
in reverse. xlsxwriter has constant_memory, which flushes each row to disk as soon as it is
written and never holds more than one row. calamine parses into a compact Rust structure and hands
back one table. Polars keeps columnar Arrow buffers rather than Python objects.
from openpyxl import load_workbook
# Streaming read: rows arrive as tuples, the grid is never built.
book = load_workbook("huge.xlsx", read_only=True, data_only=True)
sheet = book["Data"]
total = 0.0
for row in sheet.iter_rows(min_row=2, values_only=True):
if row[3] is not None:
total += row[3]
book.close()
print(f"{total:,.2f}")
The catch with every streaming mode is that it gives up random access: you get the rows in order, once, and you cannot go back. That trade is nearly always worth taking on a file large enough to care about, and Speed Up openpyxl with read-only Mode covers what changes in the API when you do.
What the wrong choice costs
Three failure modes account for most of the wasted afternoons in this ecosystem, and each is a library choice made on the wrong axis.
Reaching for pandas when the job is cell-level. to_excel writes values; it does not write borders,
merged title rows, conditional formats or column widths. Scripts that try to force those through
pandas alone end up reopening the file with openpyxl anyway, and the honest version of that script
is shorter than the one that resisted it.
Reaching for openpyxl when the job is bulk. Writing 300,000 rows through the normal workbook API
means 300,000 Python objects and a save step that serialises all of them at once. The same export
through xlsxwriter in constant_memory mode finishes in a fraction of the time and a fixed amount
of RAM, and the code is not meaningfully longer.
Reaching for xlwings when the job is data. Every read and write through the automation interface is a cross-process call; a loop that writes cells one at a time will take minutes where a file-level library takes a second. When xlwings is genuinely needed, the fix is to move whole ranges in one call rather than to abandon it.
# Slow: one cross-process round trip per cell.
for row_index, value in enumerate(values, start=2):
sheet.range(f"B{row_index}").value = value
# Fast: one round trip for the whole block.
sheet.range("B2").value = [[value] for value in values]
A thirty-second checklist
Answer these in order and the choice usually makes itself. Does the output need to land inside a workbook that already exists? If yes, openpyxl, and no other file-level library will do. Does the result need native charts, conditional formats or more than about 100,000 rows? If yes, xlsxwriter. Is the hard part the transform — joins, groupings, reshaping? Then pandas or Polars, with the file libraries reduced to the first and last line of the script. Does the answer depend on Excel recalculating something, refreshing a connection, or running an existing macro? Only then does an application driver enter the picture.
The one question worth asking before any of these: does the deliverable have to be a spreadsheet at all? A CSV or Parquet handoff between two automated systems removes the entire question, and Excel vs CSV vs Parquet for Python Data Pipelines makes that case in full. Excel earns its place when a person opens the file — not when a machine reads it.
Version traps worth knowing before you pin
Most of the confusing errors in this ecosystem are version problems wearing a library's name, and four of them recur often enough to be worth memorising.
xlrd 2.0 dropped support for .xls entirely. A requirements file that says xlrd and a workbook
that ends in .xls will produce XLRDError: Excel xlsx file; not supported or a flat refusal,
depending on which way round the mismatch falls. Pin xlrd==1.2.0 if you genuinely need the legacy
format, or install python-calamine and stop thinking about it — it reads .xls, .xlsx and
.xlsb through one parser. Fix "openpyxl does not support the old .xls format"
walks through that specific message.
pandas needs an engine and does not install one. ModuleNotFoundError: No module named 'openpyxl'
from a script that only imported pandas is not a bug — pandas deliberately leaves the parser as an
optional dependency. Install the engines you actually use and pin them alongside pandas itself.
openpyxl's data_only=True returns None, not a value, when the workbook was last written by a
tool that never calculated the formulas — which includes every file openpyxl itself produced. The
cached value only exists if Excel put it there. That trap is set out in
Read Formula Results with openpyxl data_only.
Engine names changed. engine="calamine" in read_excel requires a reasonably recent pandas and
the python-calamine package — the pip name and the engine string are deliberately different, and
installing calamine instead gets you an unrelated project.
pandas>=2.2 # engine="calamine" support
openpyxl>=3.1 # .xlsx read/write and in-place edits
XlsxWriter>=3.2 # formatted writes, constant_memory
python-calamine>=0.2 # fast reads, .xls/.xlsb/.xlsx in one parser
A pinned requirements block like that one is worth more than any benchmark: it makes the container that runs at 06:00 behave the way the laptop did, which is the failure this ecosystem produces most often. Keep Excel Report Settings in a Config File covers the rest of making a job reproducible once the libraries are settled.
Key takeaways
- The libraries divide by job, not by quality: pandas and Polars shape data, openpyxl and xlsxwriter produce files, xlwings and pywin32 drive the application.
- openpyxl is the only one that can edit an existing workbook, and that edit is lossy for pivot caches, some charts and macros unless you take precautions.
- xlsxwriter cannot open a file, which is exactly why it can stream a million rows in constant memory and expose the richest formatting API.
pandas.read_excel()is a front end over an engine; swapping in calamine is usually the single biggest read speedup available, and it needs one keyword argument.- Application drivers earn their overhead only when the value comes from Excel itself — recalculated formulas, native PDF output, an existing macro — never for bulk writing.
- Most production scripts use two libraries together, and the combination is the answer, not a compromise.
Frequently asked questions
Which library should I install first? pandas plus openpyxl. That pair reads and writes .xlsx, covers the read-transform-write shape of most reporting jobs, and needs no Excel installation. Add xlsxwriter when the output needs charts or heavy formatting, and python-calamine when reads become the bottleneck.
Can openpyxl and xlsxwriter be used in the same script? Not on the same file handle, but easily in the same job. openpyxl edits an existing workbook in place; xlsxwriter builds a new one from scratch and cannot open a file. A common split is to write the data-heavy sheets with xlsxwriter and reopen the result with openpyxl only if something must be patched afterwards.
Do any of these libraries need Excel installed? Only xlwings and pywin32 do — they drive a real copy of Excel through its automation interface, so they need Windows or macOS with Excel present. openpyxl, xlsxwriter, pandas, Polars and calamine parse and write the file format directly and run happily on a headless Linux container.
Is Polars a replacement for pandas in Excel work? For the transform step, often yes — it is faster and its expression API is stricter. For the Excel edges it is thinner: read_excel delegates to calamine or openpyxl anyway, and write_excel wraps xlsxwriter. Choose Polars for the middle of the pipeline, not because of its spreadsheet support.
What reads .xlsb and legacy .xls files? Binary .xlsb needs pyxlsb or python-calamine; legacy .xls needs xlrd pinned to 1.2.0, or calamine, which handles both. openpyxl deliberately refuses anything that is not an OOXML file and raises InvalidFileException.
How much does the choice actually matter? For a 500-row monthly report, almost not at all — any of them finishes in under a second. The choice starts to matter at three points: when files grow past roughly 100,000 rows, when the output has to carry native charts or conditional formats, and when an existing workbook must be edited without losing what is already in it.
Related
- Up one level: Getting Started with Python Excel Automation — the read, transform and write pipeline these libraries slot into.
- openpyxl vs pandas for Excel Automation — the two most-installed options, compared on the work each does well.
- pandas vs Polars for Excel Workflows — where Polars wins in the middle of a pipeline and where it is still thin at the edges.
- Benchmark Python Excel Read and Write Speed — a repeatable harness that measures the libraries on your own files.
- Pick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods — the engine matrix by file extension, with the install lines.
- Excel vs CSV vs Parquet for Python Data Pipelines — when to stop using Excel as the interchange format.
- When to Use xlwings Instead of openpyxl — the four jobs that genuinely need a running copy of Excel.
- openpyxl vs xlsxwriter vs pandas.ExcelWriter — the writing-side comparison in depth.
- Working with Large Excel Files in Python — what changes once the file no longer fits comfortably in memory.