Extract Report Header Fields from an Excel Sheet with Python
Exports from accounting, ERP and banking systems rarely start with the table. The first rows carry a title, then fields like "Branch: Leeds", "Period: 01/01/2026 - 31/03/2026", "Generated" with a timestamp, and "Currency: EUR" — and only then the column headers. pd.read_excel either chokes on that block or, with skiprows, throws it away, even though those fields are exactly what distinguishes one file from the next. This guide, part of Extracting Data from Messy Excel Sheets, reads the header fields and the table from the same sheet, wherever the block happens to start.
Prerequisites
pip install pandas openpyxl
Two sample reports with the same layout, the second shifted down by two rows — the kind of variation that breaks a fixed skiprows:
import datetime as dt
import re
import pandas as pd
from openpyxl import Workbook, load_workbook
def make_report(path, branch, blank_rows_above):
wb = Workbook()
ws = wb.active
for _ in range(blank_rows_above):
ws.append([])
ws.append(["Sales by Branch"])
ws.append(["Branch:", None, branch])
ws.append(["Period: 01/01/2026 - 31/03/2026"])
ws.append(["Generated", dt.datetime(2026, 4, 2, 8, 15)])
ws.append(["Currency", "EUR"])
ws.append([])
ws.append(["Product", "Units", "Revenue"])
ws.append(["Tea", 10, 120.0])
ws.append(["Coffee", 4, 88.5])
wb.save(path)
make_report("leeds.xlsx", "Leeds", 0)
make_report("york.xlsx", "York", 2)
Step 1 — Recognise the three kinds of header line
The block above the table mixes three shapes:
- A label and a value in separate cells — "Branch:" in A, "York" in C, with an empty or merged cell between.
- A label and a value in one text cell — "Period: 01/01/2026 - 31/03/2026".
- A title — a single text cell with no colon.
Scanning with values_only=True and dropping empty cells turns the first shape into a simple pair, whatever the spacing. A regular expression splits the second. Anything else with a single text value is the title.
INLINE = re.compile(r"^\s*([A-Za-z][A-Za-z ]{0,30}?)\s*:\s*(.+?)\s*$")
print(INLINE.match("Period: 01/01/2026 - 31/03/2026").groups())
# ('Period', '01/01/2026 - 31/03/2026')
print(INLINE.match("Sales by Branch")) # None
Limiting the label to 30 letters and spaces stops a sentence that happens to contain a colon — a footnote, say — from being taken as a field.
Step 2 — Scan until the table header
Read rows from the top, collecting fields, until you reach the row that contains the table's column headers. Its position is also what pandas needs to read the table:
def read_report(path, table_columns=("Product", "Units", "Revenue"), max_rows=30):
ws = load_workbook(path, read_only=True, data_only=True).active
fields, title, header_row = {}, None, None
for idx, row in enumerate(ws.iter_rows(max_row=max_rows, values_only=True)):
values = [v for v in row if v not in (None, "")]
if not values:
continue
if set(table_columns) <= {str(v).strip() for v in values}:
header_row = idx
break
first = values[0]
if isinstance(first, str) and len(values) == 1:
match = INLINE.match(first)
if match:
fields[match.group(1).lower()] = match.group(2)
elif title is None:
title = first.strip()
elif isinstance(first, str):
fields[first.strip().rstrip(":").strip().lower()] = values[1]
if header_row is None:
raise ValueError(f"{path}: columns {table_columns} not found in the first {max_rows} rows")
table = pd.read_excel(path, header=header_row)
return title, fields, table
title, fields, table = read_report("york.xlsx")
print(title) # Sales by Branch
print(fields)
print(table)
The York file has two extra blank rows at the top, and it still works: the scan counts every row, and header= takes the same zero-based position. Opening in read_only mode keeps the scan cheap even on large exports, since only the first rows are read. The error names the file and the missing columns when a report has changed layout, which is far easier to act on than a KeyError later. Locating header rows by content is covered further in Find the Header Row in an Excel Sheet with a Shifting Layout.
Step 3 — Turn field text into typed values
Header fields arrive as text or as whatever type Excel stored. The period is text that needs splitting into two dates; the generated timestamp is already a datetime because the cell held a real date:
def parse_period(text):
parts = re.split(r"\s+(?:-|–|to)\s+", text.strip())
if len(parts) != 2:
raise ValueError(f"cannot parse period {text!r}")
return [pd.to_datetime(p, dayfirst=True) for p in parts]
start, end = parse_period(fields["period"])
print(start.date(), end.date()) # 2026-01-01 2026-03-31
print(type(fields["generated"]).__name__) # datetime
Splitting on a dash surrounded by spaces, rather than on any dash, keeps ISO dates such as 2026-01-01 - 2026-03-31 intact. dayfirst=True is essential for European exports: without it, 01/03/2026 would be read as 3 January. If a system writes dates in more than one style, parse with an explicit format= and let it fail loudly.
Step 4 — Check the fields you depend on
A report missing its branch or period should stop the run, not produce rows that cannot be traced. Check required fields and their types in one place:
REQUIRED = {"branch": str, "period": str, "currency": str}
def check_fields(path, fields):
problems = [f"missing {k}" for k in REQUIRED if k not in fields]
problems += [f"{k} is {type(fields[k]).__name__}" for k, t in REQUIRED.items()
if k in fields and not isinstance(fields[k], t)]
if problems:
raise ValueError(f"{path}: " + "; ".join(problems))
check_fields("york.xlsx", fields)
print("fields ok")
Unknown extra fields are fine — systems add lines like "Printed by" — so only the fields the pipeline uses are enforced. Stricter validation of the table itself is covered in Validating Excel Data with Python.
Step 5 — Tag every row and combine reports
Once each report is read, copy the header fields onto its rows as ordinary columns and stack the files. The branch and period now travel with the data:
frames = []
for path in ["leeds.xlsx", "york.xlsx"]:
title, fields, table = read_report(path)
check_fields(path, fields)
start, end = parse_period(fields["period"])
frames.append(table.assign(
branch=fields["branch"], period_start=start, period_end=end,
currency=fields["currency"], source_file=path,
))
combined = pd.concat(frames, ignore_index=True)
print(combined[["branch", "Product", "Revenue", "period_start"]].values.tolist())
print(combined.groupby("branch")["Revenue"].sum().to_dict()) # {'Leeds': 208.5, 'York': 208.5}
Keeping source_file as well costs one column and answers "where did this row come from?" when a number is questioned months later. Combining many files in a folder follows the same pattern; see Combine Multiple Excel Files into One in Python.
Tagging every row repeats the same few values thousands of times, which is fine for a report of this size and makes filtering trivial. For very large combined datasets, or when the header block has many fields, store one row per report in a separate "runs" table — file name, branch, period, generated time — and give the data rows only a run identifier. Written as two sheets or two database tables, that keeps the data compact and makes it easy to answer questions about the runs themselves, such as which branches have not sent this quarter's report.
Common pitfalls
- Fixed skiprows. A report that gains one line of header — a new "Printed by" field — shifts every column name. Find the table header by content.
- Two fields on one row. Some layouts put the title in A1 and the period in the top-right corner of the same row. The scan pairs the first two non-empty cells, so it would read the period as the title's value; for such layouts, walk the row's cells two at a time and check each label against the fields you expect.
- Merged label cells. openpyxl returns the value only in the top-left cell of a merge, and
Noneelsewhere; dropping empty values handles that. - Formula-driven headers. If the period is a formula, open with
data_only=Trueto get its cached result, as the scan does.
Conclusion
Report header blocks carry the context that tells files apart. Scan the top rows with openpyxl, pairing labels and values across empty cells and splitting "Label: value" text; stop at the table's column-header row and pass its index to pandas. Parse periods and dates explicitly, fail on missing required fields, and attach the fields to every row so combined data keeps its source.
Frequently asked questions
How do I read the values above the table in an Excel report? Scan the first rows with openpyxl, treating a text cell followed by a value as a label-value pair and a single "Label: value" cell as an inline pair. Stop when you reach the row that holds the table's column headers.
How does pandas skip the header block? Pass header= the zero-based index of the table's column-header row, which the scan returns. pandas then reads the table below it and ignores everything above.
What if the label and value are separated by empty cells? Ignore empty cells when pairing. Take the first non-empty cell in the row as the label and the next non-empty cell as the value, which handles merged cells and spacer columns.
Why attach header fields to every row? Once several reports are combined, the branch or period printed above each table is the only thing that tells rows apart. Adding them as columns keeps that information with the data.
Related
- Up one level: Extracting Data from Messy Excel Sheets — pulling structure out of irregular sheets.
- Read Multiple Tables from One Excel Sheet — when one sheet holds several blocks.
- Remove Subtotal and Total Rows from an Excel Export — cleaning the bottom of the same reports.
- Read Excel with Multi-Row Headers in pandas — tables whose own header spans rows.
More on messy spreadsheets
- How-toFreeze the Header Row in Excel with openpyxl
- How-toHandle Merged Cells When Reading Excel with pandas
- How-toUnmerge Cells and Fill the Values with openpyxl
- How-toCombine Sheets with Different Layouts into One Table
- How-toExtract Hyperlink URLs from Excel Cells with openpyxl
- How-toFind the Header Row in an Excel Sheet with a Shifting Layout