xlrd
xlrd reads the legacy Excel .xls binary format into Python. You call open_workbook, get a Book, pick a Sheet by index or name, and read cells as typed values. That is the whole library, and since 2.0.0 shipped in December 2020 it is deliberately all it does: support for .xlsx, .xlsb, and .ods was removed, and passing one of those files now raises XLRDError immediately. Most developers meet xlrd by accident through pandas.read_excel on a file exported from a 2003-era system, not because they went looking for it.
Correct and reliable for the one job it still claims: reading genuine .xls files. If your file is .xlsx, xlrd is not the answer to the error message you searched for, and pinning it below 2.0 to silence that error is the wrong move.
Use it if
- You actually have .xls files: exports from old ERP systems, bank statement downloads, or government portals that never moved past Excel 97-2003
- You need per-cell type information rather than a guess: ctype tells you XL_CELL_DATE, XL_CELL_NUMBER, XL_CELL_TEXT, XL_CELL_BOOLEAN, XL_CELL_ERROR, or empty, which pandas flattens away
- You need styling or merged-cell data out of an .xls, since openpyxl reads none of the old binary format and formatting_info=True is the only route to XF records
- You are reading a very large .xls in a memory-tight worker and want on_demand=True with unload_sheet between sheets
- You need a pure-Python reader with no compiled extensions, on a platform where wheels for Rust or C-based readers do not exist
- Your file is .xlsx. xlrd 2.x raises XLRDError('Excel xlsx file; not supported') on sight, and pinning xlrd<2 to make that error go away resurrects an unmaintained xlsx parser instead of just installing openpyxl
- You only want a DataFrame: pandas.read_excel already chooses the engine per extension, so installing openpyxl and python-calamine covers every format your users will send and you never import xlrd yourself
- You need to write or edit spreadsheets: xlrd is read-only by design, and its sibling xlwt only writes the same obsolete format
- The file is password-protected or corrupted: encrypted workbooks are explicitly unsupported, and the ignore_workbook_corruption flag skips checks rather than repairing anything
- Maintenance matters to your risk review: GitHub issues are turned off on the repo, exactly one bug-fix release landed between December 2020 and June 2025, and the repo description tells you to use openpyxl where you can
Setup reality
pip install xlrd is genuinely painless: pure Python, no compiled extensions, no wheel problems on Alpine or ARM. The trap is what happens next. Teams hit 'Excel xlsx file; not supported' from pandas, search it, and add xlrd==1.2.0 to requirements.txt, which pulls back a version whose xlsx path pandas will refuse to use anyway (pandas dropped xlrd for xlsx in 1.2) and which carries the XML parsing risks that motivated the removal. The correct fix is almost always openpyxl for .xlsx and xlrd only for .xls. Also expect no type hints, no py.typed marker, Python-2 era docs, and log output written to sys.stdout by default unless you pass your own logfile.
Patterns
Open a workbook and list its sheetsopen-workbook
import xlrd
book = xlrd.open_workbook("legacy.xls")
print(book.nsheets, book.sheet_names())
sheet = book.sheet_by_index(0)
print(sheet.name, sheet.nrows, sheet.ncols)
print(sheet.cell_value(rowx=0, colx=0))sheet_by_name raises XLRDError('No sheet named ...') rather than KeyError, so a bare except KeyError will not catch a typo in the sheet name.
Check what the bytes actually are before parsingdetect-file-format
import xlrd
fmt = xlrd.inspect_format("upload.bin")
# 'xls', 'xlsx', 'xlsb', 'ods', 'zip', or None
print(fmt, xlrd.FILE_FORMAT_DESCRIPTIONS[fmt])
if fmt != "xls":
raise ValueError(f"expected .xls, got {fmt}")Users rename .xlsx to .xls constantly, so extension checks lie. inspect_format reads the file signature and also accepts raw bytes via content=.
Read every row as a list of valuesiterate-rows
sheet = book.sheet_by_index(0)
header = sheet.row_values(0)
for rx in range(1, sheet.nrows):
record = dict(zip(header, sheet.row_values(rx)))
print(record)
column = sheet.col_values(2, start_rowx=1)row_values returns plain Python values with all type information dropped. Use sheet.row(rx) instead if you need Cell objects with ctype attached.
Branch on the cell type instead of guessingcell-types
from xlrd import (
XL_CELL_EMPTY, XL_CELL_TEXT, XL_CELL_NUMBER,
XL_CELL_DATE, XL_CELL_BOOLEAN, XL_CELL_ERROR,
)
cell = sheet.cell(1, 2)
if cell.ctype == XL_CELL_NUMBER:
value = cell.value # always a float
elif cell.ctype == XL_CELL_TEXT:
value = cell.value
elif cell.ctype == XL_CELL_EMPTY:
value = NoneEvery numeric cell arrives as a float, so an order id of 100234 reads as 100234.0 and needs an explicit int() before you use it as a key.
Turn Excel serial numbers into datetimesconvert-dates
from datetime import datetime
from xlrd import XL_CELL_DATE, xldate_as_datetime
cell = sheet.cell(1, 0)
if cell.ctype == XL_CELL_DATE:
dt: datetime = xldate_as_datetime(cell.value, book.datemode)
print(dt.date())book.datemode is not decorative: files authored on classic Mac Excel use the 1904 epoch, and hard-coding 0 silently shifts every date by 1462 days.
Parse an upload without writing it to diskread-from-bytes
blob = request.files["sheet"].read()
book = xlrd.open_workbook(file_contents=blob)
sheet = book.sheet_by_index(0)When file_contents is given the filename argument is ignored except in log messages, so error output will not tell you which upload failed unless you pass filename too.
Route xlsx files to openpyxl instead of crashinghandle-xlsx-rejection
from xlrd import XLRDError
try:
book = xlrd.open_workbook(path)
rows = [book.sheet_by_index(0).row_values(r)
for r in range(book.sheet_by_index(0).nrows)]
except XLRDError:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
rows = [list(r) for r in wb.active.iter_rows(values_only=True)]The message is literally 'Excel xlsx file; not supported'. Catching XLRDError and branching is the fix; downgrading to xlrd 1.2.0 is not, because pandas will not use it for xlsx either.
Read an .xls into pandas explicitlypandas-engine
import pandas as pd
df = pd.read_excel("legacy.xls", sheet_name=0, engine="xlrd")
# same file, no xlrd needed:
df = pd.read_excel("legacy.xls", engine="calamine")pandas selects the engine from the extension, so xlrd only ever gets .xls. If you install python-calamine you can drop the xlrd dependency and keep one code path for both formats.
Keep memory down on a large workbookon-demand-loading
book = xlrd.open_workbook("big.xls", on_demand=True)
for name in book.sheet_names():
sheet = book.sheet_by_name(name)
process(sheet)
book.unload_sheet(name)
book.release_resources()Without on_demand every sheet is parsed into memory at open time. unload_sheet drops one sheet, release_resources closes the mmap; forgetting the latter keeps the file handle open on Windows.
Read merged ranges and cell stylesmerged-cells-and-formatting
book = xlrd.open_workbook("styled.xls", formatting_info=True)
sheet = book.sheet_by_index(0)
for rlo, rhi, clo, chi in sheet.merged_cells:
print(rlo, rhi, clo, chi, sheet.cell_value(rlo, clo))
xf = book.xf_list[sheet.cell_xf_index(0, 0)]
font = book.font_list[xf.font_index]
print(font.bold, book.colour_map.get(font.colour_index))merged_cells is empty unless formatting_info=True, and the ranges are half-open: only the top-left cell of a merge holds the value, the rest read as empty.
Stop padding short rows with empty cellsragged-rows
book = xlrd.open_workbook("sparse.xls", ragged_rows=True)
sheet = book.sheet_by_index(0)
for rx in range(sheet.nrows):
print(rx, sheet.row_len(rx), sheet.row_values(rx))By default every row is padded out to sheet.ncols, which wastes memory on wide sparse sheets. With ragged_rows=True you must use row_len(rx) instead of ncols or you will index past the end.
Recover text from a file with a bad codepage recordfix-encoding
book = xlrd.open_workbook("old.xls", encoding_override="cp1251")
print(book.codepage, book.encoding)Only reach for this when text comes back as mojibake. Since 2.0.0 the fallback for a missing CODEPAGE record is iso-8859-1 rather than ascii, so files that used to raise now decode into garbage instead.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| openpyxl | PyPI | The file is .xlsx or .xlsm and you need to read or write it. |
| python-calamine | PyPI | You want one Rust-backed reader that handles xls, xlsx, xlsb, and ods without branching on extension. |
| pandas | PyPI | You want a DataFrame and would rather let read_excel pick the engine for you. |