Skip Rows and Set the Header When Reading Excel with pandas
Excel files made by people rarely start with the data. There is a company banner, a title, an "as at" date, a blank row, then the actual column headings — and pandas, reading from row zero, dutifully names your columns Unnamed: 0 through Unnamed: 7. The fix is two arguments, but knowing which one to reach for, and what to do when the preamble changes height every month, is what makes an import robust. This guide covers both. It builds on the basics in Reading Excel Files with pandas.
Prerequisites
pip install pandas openpyxl
A file shaped like the ones that cause the problem:
import pandas as pd
with pd.ExcelWriter("export.xlsx", engine="xlsxwriter") as writer:
frame = pd.DataFrame({
"Region": ["North", "South", "West"],
"Units": [412, 388, 265],
"Revenue": [5150.00, 4268.00, 3511.25],
})
frame.to_excel(writer, sheet_name="Report", index=False, startrow=4)
sheet = writer.sheets["Report"]
sheet.write(0, 0, "ACME Corporation")
sheet.write(1, 0, "Regional revenue report")
sheet.write(2, 0, "As at 15 August 2026")
Step 1 — Look before you parse
Never guess the layout. Read the top of the sheet with no header at all and print it:
import pandas as pd
peek = pd.read_excel("export.xlsx", header=None, nrows=8)
print(peek.to_string())
# 0 1 2
# 0 ACME Corporation NaN NaN
# 1 Regional revenue report NaN NaN
# 2 As at 15 August 2026 NaN NaN
# 3 NaN NaN NaN
# 4 Region Units Revenue
# 5 North 412 5150.0
header=None stops pandas promoting anything to column names, and nrows=8 keeps it cheap on a large file. The header is clearly row 4.
Step 2 — Set the header row
With the index known, one argument does the job:
df = pd.read_excel("export.xlsx", header=4)
print(df.columns.tolist()) # ['Region', 'Units', 'Revenue']
print(len(df)) # 3
Notice what you did not need: skiprows. Setting header=4 already tells pandas to ignore rows 0 through 3 and start data at row 5. Combining both is the most common source of confusion, because header is interpreted relative to what remains after skipping:
# Equivalent to header=4 — the counting restarts after the skip.
df = pd.read_excel("export.xlsx", skiprows=4, header=0)
# NOT equivalent: skips 4 rows, then takes the 5th remaining row as header,
# which is the first data row. Almost never what you want.
df = pd.read_excel("export.xlsx", skiprows=4, header=4)
The rule to remember: use header= alone when the preamble is simply above the header. Reach for skiprows only when you need to discard rows that are not contiguous with the top, which the callable form handles.
| Goal | Argument |
|---|---|
| Header is on row 4, preamble above | header=4 |
| Two stacked header rows | header=[0, 1] |
| No header at all; supply names | header=None, names=[...] |
| Drop scattered rows anywhere | skiprows=lambda i: ... |
| Drop trailing total rows | skipfooter=2 |
| Read only the first 500 data rows | nrows=500 |
Step 3 — Handle stacked headers
Exports from reporting tools often stack a group row above a detail row — Q1 spanning three columns, then Units, Revenue, Margin beneath. Pass a list and pandas builds a MultiIndex:
import pandas as pd
df = pd.read_excel("quarterly.xlsx", header=[0, 1])
print(df.columns[:3].tolist())
# [('Q1', 'Units'), ('Q1', 'Revenue'), ('Q1', 'Margin')]
A MultiIndex is awkward to work with downstream, so flatten it immediately. Merged group cells leave Unnamed: fragments in the upper level, which need dropping as you join:
def flatten(columns):
"""Join MultiIndex levels, ignoring the Unnamed fragments merges leave."""
flat = []
for parts in columns:
keep = [
str(p).strip() for p in parts
if p is not None and not str(p).startswith("Unnamed:")
]
flat.append("_".join(keep) if keep else "unnamed")
return flat
df.columns = flatten(df.columns)
print(df.columns.tolist())
# ['Q1_Units', 'Q1_Revenue', 'Q1_Margin', 'Q2_Units', ...]
The Unnamed: filter is essential because Excel stores a merged cell's value only in its top-left cell — the rest read as blank, so a header spanning three columns produces one real name and two Unnamed: placeholders. The wider treatment of that behaviour is in handling merged cells when reading Excel with pandas.
Step 4 — Find the header row automatically
Hard-coding header=4 works until the month somebody adds a line to the title block. The durable answer is to search for the header by its content:
import pandas as pd
def find_header_row(path, required, sheet_name=0, search=20):
"""Return the index of the first row containing all required column names."""
wanted = {str(name).strip().lower() for name in required}
preview = pd.read_excel(path, sheet_name=sheet_name,
header=None, nrows=search)
for index, row in preview.iterrows():
values = {str(v).strip().lower() for v in row if pd.notna(v)}
if wanted <= values:
return int(index)
raise ValueError(
f"No header row in the first {search} rows of {path} contains "
f"{sorted(required)} — has the export format changed?"
)
def read_report(path, required, sheet_name=0, **kwargs):
header = find_header_row(path, required, sheet_name=sheet_name)
return pd.read_excel(path, sheet_name=sheet_name, header=header, **kwargs)
df = read_report("export.xlsx", ["Region", "Units", "Revenue"])
print(df.head())
Raising when nothing matches is deliberate. A silent fallback to header=0 produces a frame full of Unnamed: columns that fails confusingly three steps later, whereas this error names the file and the columns it expected. That is the same principle behind validating Excel columns before import.
Step 5 — Drop trailing rows and pick columns
Exports often end with a blank line and a grand total. skipfooter removes them:
df = pd.read_excel("export.xlsx", header=4, skipfooter=2)
Be aware of the cost: skipfooter forces pandas down a slower Python-level path, because it cannot know where the end is until it has read everything. On a large sheet it is faster to read normally and slice:
df = pd.read_excel("export.xlsx", header=4)
df = df.iloc[:-2] # drop the last two rows
# Better still, drop by content rather than position.
df = df[df["Region"].notna() & (df["Region"] != "Total")]
Dropping by content survives a month where the export has one trailing row instead of two, which position-based slicing does not.
Finally, read only the columns you need. It is faster and it removes a whole class of surprise from columns you never look at:
df = pd.read_excel("export.xlsx", header=4, usecols=["Region", "Revenue"])
df = pd.read_excel("export.xlsx", header=4, usecols="A:C") # by letter
df = pd.read_excel("export.xlsx", header=4, usecols=lambda c: not c.startswith("_"))
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
Columns named Unnamed: 0, Unnamed: 1 | Header index points at a blank row | Peek with header=None and set the right index. |
| Data missing its first row | skiprows and header both set | Use header= alone; header counts after the skip. |
| Columns are tuples | Multi-row header read as a MultiIndex | Flatten with a join, dropping Unnamed: parts. |
Region column holds a Total row | Footer not removed | Filter by content, not just skipfooter. |
| Works one month, breaks the next | Preamble height changed | Detect the header row by its content. |
| Read is very slow | skipfooter forces a Python parse | Read fully and slice the frame instead. |
ValueError: Passed header=4 but only 3 lines | Sheet shorter than expected, or wrong sheet | Check sheet_name and peek first. |
| Numbers read as text | Header row absorbed into the data | Fix the header index; the dtype follows. |
Performance and scale notes
skiprows and header do not save any reading. pandas still parses every row of the sheet — the arguments only decide what is kept. The argument that genuinely reduces work is usecols, which avoids materialising columns entirely, and nrows, which stops early.
For a wide export where you use six of sixty columns, the difference is substantial:
import time
import pandas as pd
for label, kwargs in [
("everything", {}),
("six columns", {"usecols": ["Region", "Units", "Revenue",
"Owner", "Status", "Updated"]}),
]:
start = time.perf_counter()
frame = pd.read_excel("wide_export.xlsx", header=4, **kwargs)
print(f"{label:<14} {time.perf_counter() - start:6.2f}s {frame.shape}")
Three habits follow. Detect the header once per file, not per sheet — the two-pass read costs an extra parse of twenty rows, which is negligible, but running it inside a loop over forty sheets is not. Cache the index when the sheets share a layout.
Avoid skipfooter on large files. It disables the fast path entirely. Filtering by content after a normal read is both faster and more robust.
Combine detection with chunked reading for very large sheets. Find the header from a cheap preview, then stream the body with the approach in reading large Excel files in chunks with pandas, so peak memory stays flat regardless of row count. And where the file arrives as a legacy format, convert it first — the parse cost dominates everything above, and converting .xls to .xlsx removes it permanently.
Conclusion
Reading an Excel export with a title block comes down to knowing that header= counts rows in the original file and does the skipping for you, while skiprows renumbers everything after it. Peek at the top with header=None before writing the real read. Flatten multi-row headers immediately and drop the Unnamed: fragments that merged cells leave. And when the preamble height is not stable — which, over enough months, it never is — detect the header row by looking for the column names you expect, and raise a clear error when they are not there.
Frequently asked questions
What is the difference between skiprows and header?skiprows discards rows before pandas looks at the file; header names which of the remaining rows holds the column names. Passing header=3 alone is usually enough, because pandas then treats rows 0 to 2 as ignorable preamble and starts data at row 4.
How do I read a file with two stacked header rows?
Pass a list, for example header=[0, 1]. pandas builds a MultiIndex from both rows, which you can then flatten into single names by joining the levels with an underscore.
My export has a total row at the bottom — how do I drop it?
Use skipfooter with the number of trailing rows to ignore. It requires a Python-level parse, so on very large files it is faster to read everything and slice the frame instead.
The number of preamble rows changes every month. What then?
Do not hard-code it. Read the first twenty rows with header=None, find the row containing your known column names, and pass that index as header.
Why are my columns named Unnamed: 0, Unnamed: 1?
pandas took a blank row as the header. Either the header index is wrong, or the real header sits below merged title cells. Read with header=None first and print the top rows to see where the names actually are.
Related
- Up to the parent: Reading Excel Files with pandas — the full reading toolkit.
- Handle Merged Cells When Reading Excel with pandas — why stacked headers leave
Unnamed:gaps. - Read Specific Columns from Excel with pandas — the
usecolsargument in depth. - Validate Excel Columns Before Import with pandas — failing loudly when an export changes shape.
- Read Large Excel Files in Chunks with pandas — combining header detection with streaming.