mrkeyoor.com_
Thu 06 Aug 07:43 UTC
PyPIDataupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The reading API (open_workbook, sheet_by_index, cell_value, xldate_as_datetime) has not moved in over a decade and 2.0.2 changed nothing callers touch; the 4 comes from 2.0.0 deleting xlsx and ods support outright, which broke a very large amount of working code.
Docs3/5Read the Docs covers the full API, dates, unicode, and on-demand loading with real explanations of BIFF quirks, but the prose predates Python 3 idioms, examples use positional keyword style like cell_value(rowx=29, colx=3), and nothing signposts the format removal until you hit the exception.
Maintenance2/5GitHub issues are disabled on python-excel/xlrd, releases went 2.0.1 in December 2020 to 2.0.2 in June 2025, and the only commits since are small cleanups; nothing is broken because the format is frozen, but there is no one to answer a bug report.
Ecosystem3/521.0M weekly downloads is almost entirely transitive: pandas lists it as the .xls engine and old requirements files pin it. Outside that lane the python-excel ecosystem (xlwt, xlutils) is as dormant as the format itself.

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
Skip it if

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 = None

Every 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

PackageRegistryPick it when
openpyxlPyPIThe file is .xlsx or .xlsm and you need to read or write it.
python-calaminePyPIYou want one Rust-backed reader that handles xls, xlsx, xlsb, and ods without branching on extension.
pandasPyPIYou want a DataFrame and would rather let read_excel pick the engine for you.