Fix
Getting Started With Python Excel AutomationDeep dive

Fix "XLRDError: Excel xlsx file; not supported" in Python

xlrd 2.0 reads only legacy .xls files, so any code path that sends an .xlsx workbook to xlrd now fails. Find where xlrd is being chosen, switch that path to openpyxl, and port old xlrd-style code without pinning an outdated version.

A script that used to work now stops on the first read_excel call:

Text
xlrd.biffh.XLRDError: Excel xlsx file; not supported

The message is accurate. xlrd 2.0, released in December 2020, dropped support for everything except the old binary .xls format. Any code that hands an .xlsx workbook to xlrd — directly through xlrd.open_workbook, or indirectly through pandas with engine="xlrd" — now gets this error. The library you need for .xlsx is openpyxl, which pandas already uses by default. The work is finding which line still routes the file to xlrd. This page belongs to Troubleshooting Common Python Excel Errors.

Which reader handles which Excel format A read_excel call fans out to four readers by file format. Modern .xlsx and .xlsm go to openpyxl, .xls goes to xlrd, .xlsb goes to pyxlsb or calamine, and .ods goes to odfpy. Sending an .xlsx file down the xlrd route is what raises XLRDError. What pandas picks when you leave engine alone pd.read_excel(path) inspects the file's bytes openpyxl .xlsx and .xlsm xlrd .xls only (Excel 97–2003) pyxlsb or calamine .xlsb odfpy .ods

Why xlrd stopped reading .xlsx

xlrd was written for the binary .xls format that Excel used until 2007. Support for .xlsx was added later and was always the weaker half of the library, and the pandas 1.2 release notes cite potential security vulnerabilities in reading .xlsx with xlrd 1.2 and earlier. Rather than maintain a second .xlsx reader when openpyxl already did the job, the maintainers removed it and returned xlrd to its original purpose. pandas followed: from version 1.2 its default engine for .xlsx became openpyxl. pandas still passes the file to xlrd if you ask for that engine explicitly — which is exactly how the error in this page's title gets raised.

So the error is not a bug to wait out. It is the library telling you, correctly, that it no longer reads this format — and the environment your script was written in had an older xlrd that still did. That is also why the failure tends to appear "out of nowhere": a server rebuild, a fresh virtual environment or a new colleague's laptop installs xlrd 2.x, and code that never changed stops working.

Prerequisites

Bash
pip install pandas openpyxl
pip install xlrd   # only if you also read legacy .xls files

Check what is installed before changing anything; the fix depends on it:

Python
import importlib.metadata as md

for pkg in ("pandas", "openpyxl", "xlrd"):
    try:
        print(pkg, md.version(pkg))
    except md.PackageNotFoundError:
        print(pkg, "not installed")

Step 1 — Reproduce it and read the traceback

The error comes from two kinds of call, and the traceback tells you which one you have. With pandas, an explicit engine is the usual culprit:

Python
import pandas as pd

try:
    pd.read_excel("orders.xlsx", engine="xlrd")
except Exception as exc:
    print(type(exc).__module__, type(exc).__name__, exc)
# xlrd.biffh XLRDError Excel xlsx file; not supported

If the bottom of the traceback shows your own code calling xlrd.open_workbook, the script uses xlrd directly — go to Step 4. If it shows pandas' _xlrd.py reader, something told pandas to use xlrd — Step 2.

Step 2 — Stop forcing the engine

Before pandas 1.2, read_excel defaulted to xlrd for every Excel file, so older scripts often spelled the engine out, and tutorials copied it. Today the right choice is to leave engine unset. pandas reads the file's first bytes, recognises the zip container of an .xlsx, and picks openpyxl:

Python
df = pd.read_excel("orders.xlsx")          # engine chosen from the content
print(df)

Search for every place the engine is set — it hides in more places than read_excel itself:

Python
import pathlib, re

pattern = re.compile(r"""engine\s*=\s*['"]xlrd['"]|open_workbook\(""")
for path in pathlib.Path(".").rglob("*.py"):
    for n, line in enumerate(path.read_text(errors="ignore").splitlines(), 1):
        if pattern.search(line):
            print(f"{path}:{n}: {line.strip()}")

Check pd.ExcelFile(..., engine="xlrd") calls and configuration files too. A setting like io.excel.xls.reader only applies to .xls and is harmless; an engine stored in a job's YAML or passed from a command-line flag is not.

Step 3 — Check for .xlsx files wearing an .xls extension

The error also appears when a file named .xls is really an .xlsx. Systems that export spreadsheets sometimes keep the old extension after switching formats, and people rename files by hand. xlrd inspects the bytes, sees a zip archive and refuses, whatever the name says. Look at the signature:

Python
def excel_kind(path):
    with open(path, "rb") as fh:
        head = fh.read(8)
    if head.startswith(b"PK\x03\x04"):
        return "xlsx-family (zip) — read with openpyxl"
    if head.startswith(bytes.fromhex("D0CF11E0A1B11AE1")):
        return "legacy .xls (OLE2) — read with xlrd"
    return f"not an Excel workbook: {head!r}"

print(excel_kind("orders.xlsx"))
print(excel_kind("legacy.xls"))

A zip signature under an .xls name is fixed by not forcing the engine — pandas' detection goes by content. For good measure, rename the file so people and other tools are not misled. If the bytes are neither signature, the file is something else again — often HTML or CSV saved with an Excel extension — and Fix "Excel File Format Cannot Be Determined" in pandas covers that case.

Finding what sends an .xlsx file to xlrd A decision chart based on the traceback. If pandas was given engine="xlrd", remove the argument. If the code calls xlrd.open_workbook itself, port it to openpyxl. If the file is named .xls but starts with the zip signature PK, it is really an .xlsx: stop forcing the engine and rename it. If it is a genuine OLE2 .xls file, xlrd is correct and must stay installed. What does the traceback point at? engine="xlrd" Remove the engine pandas picks openpyxl itself xlrd.open_workbook Port to openpyxl rows, cells and sheets change names .xls name, PK bytes A renamed .xlsx detect by content, then rename real OLE2 .xls Keep xlrd it still reads legacy .xls

Step 4 — Port direct xlrd code to openpyxl

Scripts written against xlrd's own API need more than an engine change, because openpyxl names things differently and counts from 1 rather than 0. Here is typical xlrd-era code:

Text
book = xlrd.open_workbook("orders.xlsx")
sheet = book.sheet_by_name("Sheet1")
for r in range(1, sheet.nrows):
    sku = sheet.cell_value(r, 0)
    qty = sheet.cell_value(r, 1)

The openpyxl version reads the same values. values_only=True hands back plain tuples, which is the closest match to xlrd's row_values:

Python
from openpyxl import load_workbook

book = load_workbook("orders.xlsx", read_only=True, data_only=True)
sheet = book["Sheet1"]
for sku, qty in sheet.iter_rows(min_row=2, values_only=True):
    print(sku, qty)
book.close()

The translations you will need most often:

xlrd calls and their openpyxl replacements A lookup table. open_workbook becomes load_workbook with read_only and data_only; sheet_by_name becomes indexing the workbook by name; sheet_by_index becomes worksheets[i]; nrows and ncols become max_row and max_column; cell_value(r, c) becomes cell(r+1, c+1).value; row_values(r) becomes iter_rows with values_only; xldate_as_datetime is not needed because dates arrive as datetime objects. openpyxl replacement Index base open_workbook(path) load_workbook(path, read_only=True, data_only=True) — sheet_by_name(n) book[n] — sheet_by_index(i) book.worksheets[i] 0-based nrows / ncols max_row / max_column — cell_value(r, c) cell(r + 1, c + 1).value 1-based row_values(r) iter_rows(values_only=True) 1-based xldate_as_datetime(v, 0) not needed: dates arrive as datetime —

Two behaviour differences are worth testing for when you port. data_only=True gives you cached formula results, the same thing xlrd returned; without it, openpyxl hands you the formula text. And xlrd returned every number as a float, while openpyxl returns integers as int — code that did int(sheet.cell_value(...)) keeps working, but code that compared types or formatted with %f may print differently.

Step 5 — Keep xlrd only for real .xls files

xlrd is still the right tool for legacy .xls workbooks, and pandas will use it for them automatically. If a job reads a mix of formats, leave the engine unset and let the content decide:

Python
for name in ("orders.xlsx", "legacy.xls"):
    print(name, pd.read_excel(name).to_dict("records"))

If the job only ever reads .xlsx, uninstall xlrd from its environment. A missing optional dependency fails loudly with a clear message the day someone feeds it an .xls, which is better than a stale engine argument quietly surviving. For converting a folder of old files once and for all, see Convert .xls to .xlsx with Python.

Step 6 — Add a test so the engine cannot creep back

Once the script works, pin the behaviour with a small test that writes an .xlsx, reads it through the same function your job uses, and fails if anything routes it to xlrd again. It runs in a fraction of a second and catches the regression the day someone pastes an old snippet back in:

Python
def load_orders(path):
    return pd.read_excel(path)          # the job's real reading function

def test_reads_xlsx_without_xlrd(tmp_path):
    path = tmp_path / "orders.xlsx"
    pd.DataFrame({"sku": ["A-1"], "qty": [4]}).to_excel(path, index=False)
    frame = load_orders(path)
    assert frame.to_dict("records") == [{"sku": "A-1", "qty": 4}]

import pathlib, tempfile
with tempfile.TemporaryDirectory() as d:
    test_reads_xlsx_without_xlrd(pathlib.Path(d))
print("test passed")

In a real project the test lives in tests/ and pytest supplies tmp_path; the last three lines only run it here. Test Excel Output with pytest shows the fuller setup.

Common pitfalls

  • Pinning xlrd==1.2.0. It silences the error by reinstating a parser the maintainers removed. You inherit its bugs, and pip will eventually refuse to resolve it with a current pandas. Fix the code path instead.
  • Setting engine="openpyxl" everywhere. Better than xlrd for .xlsx, but it now breaks on genuine .xls files. Unless you have a reason, let pandas choose.
  • Forgetting data_only=True when porting. Without it, formula cells return strings like =SUM(B2:B9) where xlrd gave numbers.
  • Off-by-one errors. xlrd rows and columns start at 0; openpyxl's start at 1. Port loops carefully and compare the first and last rows against the old output.
  • Assuming the extension is the truth. Check the first bytes of any file whose origin you do not control.

Performance notes

For large .xlsx files, load_workbook(..., read_only=True) streams rows instead of building every cell in memory, which is roughly what xlrd's on-demand mode offered. If reading speed was the reason someone reached for xlrd in the first place, the calamine engine is considerably faster than either — Speed Up pandas Excel Reads with the calamine Engine has the numbers.

Conclusion

Excel xlsx file; not supported means an .xlsx workbook reached xlrd, which since version 2.0 only reads .xls. Remove any engine="xlrd" and let pandas detect the format, check the bytes of files whose extension may lie, port direct xlrd.open_workbook code to openpyxl with data_only=True and 1-based indexes, and keep xlrd installed only where genuine .xls files still arrive.

Frequently asked questions

Can I just install xlrd 1.2.0 to make the error go away? It works, but it is the wrong fix. xlrd 1.2 is unmaintained for .xlsx, its .xlsx parser was removed partly over XML security concerns, and it will not install cleanly alongside current pandas versions forever. Switch the .xlsx path to openpyxl instead.

Do I still need xlrd at all? Only for legacy .xls files (Excel 97–2003). pandas uses xlrd automatically for those when it is installed. For .xlsx, .xlsm and .xlsb you do not need it.

Why does the error appear on a file that ends in .xls? The file is really an .xlsx workbook with the wrong extension. xlrd looks at the bytes, sees a zip container, and refuses. pandas' own format detection gets this right if you stop forcing engine="xlrd".

What replaces xlrd's xldate_as_datetime? Nothing is needed with openpyxl. It converts date-formatted cells to Python datetime objects as it reads, so the conversion step disappears.

More on errors & fixes

More on errors & fixes →