Reading Excel Files with Pandas
pandas.read_excel() turns a worksheet into a DataFrame, ready for filtering, aggregation, and export. Loading a workbook is the first move in almost every automation covered in Getting Started with Python Excel Automation, and this guide is the map of that one function: how the engine works, the handful of parameters you will use constantly, and how to handle multi-sheet workbooks. Every snippet runs in order against a sample file created below, so you can paste and follow along. For a slower, fully narrated walkthrough, see How to Read Excel with Pandas Step by Step.
Install pandas and an engine
pandas does not parse Excel itself — it hands the file to an engine. openpyxl reads modern .xlsx files and is the one you need here. (Legacy .xls requires xlrd==1.2.0; xlrd 2.0 dropped .xls support. Binary .xlsb needs pyxlsb or calamine.)
pip install pandas openpyxl
Create a sample workbook
So the examples below have something to read, write a two-sheet workbook now:
import pandas as pd
sales = pd.DataFrame({
"Date": ["2024-01-05", "2024-01-06", "2024-01-07", "2024-01-08"],
"Transaction_ID": ["T-001", "T-002", "T-003", "T-004"],
"Amount": [120.50, 89.00, 240.75, 15.25],
"Category": ["Hardware", "Software", "Hardware", "Services"],
})
returns = pd.DataFrame({
"Date": ["2024-01-09", "2024-01-10"],
"Transaction_ID": ["T-005", "T-006"],
"Amount": [-30.00, -12.50],
"Category": ["Hardware", "Services"],
})
with pd.ExcelWriter("ledger.xlsx", engine="openpyxl") as writer:
sales.to_excel(writer, sheet_name="Sales", index=False)
returns.to_excel(writer, sheet_name="Returns", index=False)
print("Wrote ledger.xlsx")
The simplest read
Call read_excel with just a path. It reads the first sheet and uses row 0 as the header:
df = pd.read_excel("ledger.xlsx")
print(df)
Parameters you will use constantly
A handful of arguments cover most real reads. Here they are together on the Sales sheet:
df = pd.read_excel(
"ledger.xlsx",
sheet_name="Sales", # name or 0-based index
usecols=["Date", "Amount", "Category"], # read only these columns
parse_dates=["Date"], # convert to datetime64
dtype={"Category": "category"}, # pin types you depend on
)
print(df.dtypes)
print(df)
sheet_name— a name ("Sales"), a 0-based index (0), a list to read several sheets at once, orNonefor every sheet.usecols— limit to the columns you need; accepts a label list or an Excel range like"A:C". Smaller reads use less memory. Read specific columns from Excel with pandas covers every form this argument accepts.parse_dates— turn date columns into realdatetime64values so you can resample and compare periods.dtype— pin types explicitly. Use"string"for IDs to preserve leading zeros and avoid scientific notation;"category"for low-cardinality text.skiprows/header— drop title or metadata rows above the real header. The step-by-step guide covers these in depth.
Reading multi-sheet workbooks
Reporting files rarely live on a single tab. You have three ways to navigate them.
Inspect the tabs first
Open the workbook once with pd.ExcelFile to list sheets without loading data, then read only what you need. Reusing the same handle avoids re-parsing the file:
xls = pd.ExcelFile("ledger.xlsx", engine="openpyxl")
print("Sheets:", xls.sheet_names)
df_returns = pd.read_excel(xls, sheet_name="Returns")
print(df_returns)
Read several sheets at once
Pass a list of names (or None for all sheets) and pandas returns a dict of {sheet_name: DataFrame}:
frames = pd.read_excel("ledger.xlsx", sheet_name=["Sales", "Returns"])
print(type(frames), list(frames))
Stack the sheets into one DataFrame
A common goal is one combined table. Concatenate the dict's values, tagging each row with its source sheet:
combined = pd.concat(
[frame.assign(Source=name) for name, frame in frames.items()],
ignore_index=True,
)
print(combined)
For combining data spread across separate files rather than tabs, see Working with Multiple Excel Sheets in Python and Combine Multiple Excel Files into One.
Verify what you loaded
Right after a read, check the shape, columns, and types. This catches template drift — a renamed column or a number that arrived as text — before it corrupts a downstream calculation. The messy values a read surfaces are exactly what cleaning Excel data with pandas exists to fix:
print("Rows, cols:", combined.shape)
print(combined.dtypes)
print(combined.isna().sum())
Common errors and fixes
| Error | Cause | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'openpyxl' | No Excel engine installed | pip install openpyxl |
ValueError: Excel file format cannot be determined | Wrong extension, or a renamed CSV/HTML file | Confirm the real format; pass engine="openpyxl" |
ValueError: Worksheet named 'X' not found | Sheet name typo or template change | List pd.ExcelFile(path).sheet_names first |
IDs lose leading zeros / show as 1.0e+05 | pandas inferred a numeric dtype | Read those columns as dtype={"col": "string"} |
PermissionError on read | File is open in Excel | Close it, or copy the file before reading |
Read only what you need
The default read_excel call parses every cell of every column, then hands you a frame you probably
wanted three columns of. Narrowing the read is both the simplest optimisation and the best defence
against a source file that grows a column you never asked for:
import pandas as pd
# By name — safest, because a reordered export cannot silently shift the meaning
orders = pd.read_excel(
"orders.xlsx",
sheet_name="Orders",
usecols=["Order_ID", "Order_Date", "Region", "Quantity", "Unit_Price"],
)
# By Excel letters — useful when the export has no usable headers
letters = pd.read_excel("orders.xlsx", sheet_name="Orders", usecols="A:C,F")
# By predicate — everything except the free-text columns
def wanted(name: str) -> bool:
return not str(name).lower().startswith(("notes", "comment"))
lean = pd.read_excel("orders.xlsx", sheet_name="Orders", usecols=wanted)
print(orders.shape, letters.shape, lean.shape)
usecols is applied while parsing, so skipped cells are never converted into Python objects at all —
which is why it usually beats loading everything and dropping columns afterwards. On a wide export
the saving is large, because the columns you do not need tend to be the wide free-text ones.
Two more arguments earn their place on a first read. nrows=200 gives you a fast look at the shape
of an unfamiliar file without waiting for the whole sheet, and dtype=object stops pandas guessing
types before you have decided what they should be — which is what keeps leading zeros on an account
code and keeps a stray "n/a" visible rather than silently turning a numeric column into text.
Reading several sheets at once
sheet_name accepts more than a single name, and the return type changes with it. That difference
catches people out often enough to be worth stating plainly:
import pandas as pd
book = pd.read_excel("quarters.xlsx", sheet_name=None) # every sheet
combined = pd.concat(
[frame.assign(Quarter=name) for name, frame in book.items()],
ignore_index=True,
)
print(sorted(book), combined.shape)
Adding the sheet name as a column during the concatenation is what keeps the combined frame usable — without it the rows lose the one piece of context the workbook's structure was carrying. It is the same idea as combining multiple Excel files, applied within a single workbook.
Opening the file once with pd.ExcelFile and calling .parse() per sheet is worth doing when you
need several sheets from a large workbook: the archive is parsed once rather than per call, which on
a big file is the difference between seconds and minutes.
Check what you read before using it
A read that succeeds is not a read that worked. Three lines after every read_excel catch most
surprises while they are still cheap:
import pandas as pd
df = pd.read_excel("orders.xlsx", sheet_name="Orders", dtype=object)
print(df.shape) # did the row count look like last month's?
print(df.dtypes) # any column unexpectedly object?
print(df.isna().sum()) # which columns arrived mostly empty?
The middle line is the one that repays the habit. A numeric column reported as object means at
least one cell is not a number, and finding that here — rather than when a sum silently produces the
wrong answer — is the difference between a two-minute fix and an afternoon of reconciliation.
Headers that are not on row one
Exports routinely put a title, a timestamp and a blank row above the real header. skiprows handles
it, but hardcoding the count means one extra line upstream breaks the import. Find the header
instead:
import pandas as pd
def find_header_row(path, sheet, required, max_scan=10):
probe = pd.read_excel(path, sheet_name=sheet, header=None, nrows=max_scan, dtype=object)
for index, row in probe.iterrows():
values = {str(v).strip().lower() for v in row if pd.notna(v)}
if {r.lower() for r in required} <= values:
return index
raise ValueError(f"no header row containing {required} in the first {max_scan} rows")
header_row = find_header_row("export.xlsx", "Export", ["Order_ID", "Quantity"])
df = pd.read_excel("export.xlsx", sheet_name="Export", header=header_row, dtype=object)
print("header found on sheet row", header_row + 1, "-", list(df.columns))
The same problem appears at the bottom of a sheet. Many exports finish with a total row, a blank
line and a footnote, all of which arrive as data. skipfooter removes a fixed number of trailing
rows, but the robust version drops rows where the key column is empty:
clean = df[df["Order_ID"].notna()].copy()
print(f"{len(df) - len(clean)} trailing row(s) dropped")
Dates, decimals and the values that arrive wrong
Three conversions cause most of the confusion between what a sheet shows and what pandas reports.
Dates may arrive as real datetimes, as text in any format, or as Excel serial numbers counted from 1899-12-30. Reading raw and converting explicitly handles all three, and keeps the failures visible:
import pandas as pd
EXCEL_EPOCH = pd.Timestamp("1899-12-30")
raw = pd.Series(["2026-01-14", "15/01/2026", 45678, None, "not a date"])
numeric = pd.to_numeric(raw, errors="coerce")
from_serial = EXCEL_EPOCH + pd.to_timedelta(numeric, unit="D")
from_text = pd.to_datetime(raw.where(numeric.isna()), errors="coerce",
format="mixed", dayfirst=True)
parsed = from_serial.fillna(from_text)
print(parsed.tolist())
Numbers typed with thousands separators or currency symbols arrive as text, and one such cell turns
the whole column into object. Strip before converting, and keep the failures:
def to_number(series):
text = series.astype(str).str.replace(r"[£$€,\s]", "", regex=True)
return pd.to_numeric(text.replace({"": None, "nan": None, "n/a": None}), errors="coerce")
prices = pd.Series(["£1,234.50", "99", "n/a", ""])
converted = to_number(prices)
print(converted.tolist(), "failed:", int((converted.isna() & prices.ne("")).sum()) - 1)
Identifiers are the third case, and the most damaging. An account code of 00412 read as a number
becomes 412, and the join against a reference table then matches nothing. Read identifier columns
as text from the start — dtype={"Code": str} — or read everything as object and convert
deliberately, which is the approach checking Excel data types with
pandas
develops in full.
Merged cells and the gaps they leave
Merged header cells are common in hand-built reports, and pandas sees them as one value followed by
blanks. The result is a frame with NaN where a reader sees a repeated label:
import pandas as pd
df = pd.read_excel("regional.xlsx", sheet_name="Report", dtype=object)
df["Region"] = df["Region"].ffill() # carry the merged label down its block
print(df.head(8))
ffill is the right fix when the merge genuinely means "same as above", which is nearly always the
case for a grouping column. It is the wrong fix when a blank means "no value recorded", so apply it
per column rather than across the frame — a blanket df.ffill() invents data in every numeric
column it touches.
Where the merge is in the header itself — two rows of headers, the first merged across groups — the
tidiest approach is to read with header=[0, 1] and then flatten the resulting MultiIndex:
df = pd.read_excel("regional.xlsx", sheet_name="Report", header=[0, 1])
df.columns = [
" ".join(str(part) for part in col if not str(part).startswith("Unnamed")).strip()
for col in df.columns
]
print(list(df.columns))
A read function worth reusing
Everything above collapses into one helper that most projects end up wanting:
import pandas as pd
def read_sheet(path, sheet, required, text_columns=(), date_columns=()):
book = pd.ExcelFile(path)
if sheet not in book.sheet_names:
raise ValueError(f"sheet {sheet!r} not in {book.sheet_names}")
df = book.parse(sheet, dtype=object)
df.columns = [str(c).strip() for c in df.columns]
missing = [c for c in required if c not in df.columns]
if missing:
raise ValueError(f"{path}: missing column(s) {missing}")
for column in text_columns:
df[column] = df[column].astype("string").str.strip()
for column in date_columns:
df[column] = pd.to_datetime(df[column], errors="coerce", format="mixed")
return df
orders = read_sheet(
"orders.xlsx", "Orders",
required=["Order_ID", "Region", "Quantity"],
text_columns=["Region"],
date_columns=["Order_Date"],
)
print(orders.dtypes)
Four arguments, one predictable frame, and a clear error whenever the file is not what the job expects. Wrapping the read like this is the single most useful refactor in a growing spreadsheet pipeline, because every later stage can then assume a known shape.
Engines, and the files pandas cannot open
read_excel picks an engine from the file extension: openpyxl for .xlsx and .xlsm, calamine
or xlrd for legacy .xls, and odf for OpenDocument spreadsheets. Naming the engine explicitly
makes failures legible rather than mysterious:
import pandas as pd
modern = pd.read_excel("orders.xlsx", engine="openpyxl")
try:
legacy = pd.read_excel("archive.xls")
except Exception as exc:
print(f"{type(exc).__name__}: install a legacy engine, or convert the file first")
A .xls file is a completely different binary format, and modern pandas cannot read it without an
extra dependency. Converting once — in Excel, or headlessly with LibreOffice — is usually better than
carrying the dependency, because everything downstream then works with one format.
The other common surprise is a file whose extension lies: a CSV named .xlsx, or an HTML table
saved by a reporting system with a spreadsheet extension. Both raise a zip or parse error, and the
fix is read_csv or read_html rather than anything to do with Excel.
Read once, reuse many times
When the same workbook feeds several steps, parsing it repeatedly is pure waste — the archive is
decompressed and the XML walked every time. Open it once with pd.ExcelFile and parse from that
handle, or convert the sheet to Parquet on first read and let every later step read the fast format
instead. On a large monthly export that single change often halves a pipeline's runtime, and it
costs two lines.
Frequently asked questions
Which engine does read_excel use, and do I have to install one?
pandas does not parse Excel itself — it delegates to an engine. openpyxl handles modern .xlsx files; legacy .xls needs xlrd==1.2.0 (xlrd 2.0 dropped .xls support), and binary .xlsb needs pyxlsb or calamine.
How do I read every sheet in a workbook at once?
Pass sheet_name=None (or a list of names) and read_excel returns a dict of {sheet_name: DataFrame}. To list tabs first without loading data, open the file with pd.ExcelFile and read its sheet_names.
Why do my ID columns lose leading zeros or show as 1.0e+05?
pandas inferred a numeric dtype for those columns. Read them as text with dtype={"col": "string"} to preserve leading zeros and avoid scientific notation.
How can I read only some columns to save memory?
Pass usecols with either a list of column labels or an Excel range like "A:C". Combined with sheet_name, it avoids materializing columns and tabs you won't use.
How do I combine sheets into a single DataFrame?
Read them into a dict, then pd.concat the values with ignore_index=True. Tag each row with its source using frame.assign(Source=name) inside the comprehension so you can tell the tabs apart.
Key takeaways
pd.read_excel()is three lines at its simplest; the difficulty is defensive practice, not syntax.- Install an engine explicitly —
openpyxlfor.xlsx,xlrd==1.2.0for legacy.xls,pyxlsborcalaminefor.xlsb. - Name the sheet, name the columns with
usecols, and pin the types withdtypeso IDs keep leading zeros and dates arrive asdatetime64. - For multi-sheet files, list tabs with
pd.ExcelFile, read a dict withsheet_name=None, andpd.concatthe values with aSourcetag when you want one table. - Verify shape, dtypes, and nulls straight after the read — that habit turns schema drift and silent coercions into caught errors rather than corrupted reports.
Related
- Getting Started with Python Excel Automation — the parent guide to reading, transforming, and writing Excel with Python.
- How to Read Excel with Pandas Step by Step — the fully narrated version, including a defensive reader for scheduled jobs.
- Read Specific Columns from Excel with Pandas — target columns by name, index, or letter range with
usecols. - Working with Multiple Excel Sheets in Python — the sibling guide to navigating and combining tabs and files.
- Writing DataFrames to Excel with Pandas — the other half of the loop, once your data is loaded and transformed.
- Cleaning Excel Data with Pandas — fix the messy values a read inevitably surfaces.