How-to
Advanced Data Transformation And CleaningDeep dive

Read Multiple Tables from One Excel Sheet

Pull several stacked or side-by-side tables out of a single worksheet with pandas: split on blank rows and columns, find blocks by their header label, or read named Excel tables.

A summary sheet that a person built by hand rarely holds one table. It holds a title, a regional breakdown, a gap, a product table underneath, and perhaps a quota list off to the right — all on the same worksheet. pd.read_excel() has no idea where one ends and the next begins; it treats the whole used range as a single grid and hands back a DataFrame full of NaN and misplaced headers. This guide, part of Extracting Data from Messy Excel Sheets, shows three reliable ways to get each table out on its own.

One worksheet holding three separate tables A sheet with a title in row 1, a region table in rows 3 to 6, a product table in rows 9 to 11 and a rep quota table in columns F and G, separated by blank rows and a blank column. what read_excel sees as one grid A B C D E F G 1 Sales — March 2 3 Region Revenue Deals Rep Quota 4 North 120500 14 Ana 50000 5 South 98200 11 Ben 45000 6 West 143900 17 7 8 9 Product Units 10 Widget 420 11 Gadget 310 blank rows and columns are the only boundaries the file records

Prerequisites

Bash
pip install pandas openpyxl

The examples read a workbook called summary.xlsx shaped like the diagram above. This snippet builds it, so every block on the page can be run as written:

Python
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.title = "Summary"
ws.append(["Regional sales — March"])
ws.append([])
ws.append(["Region", "Revenue", "Deals"])
for row in (["North", 120500, 14], ["South", 98200, 11], ["West", 143900, 17]):
    ws.append(row)
ws.append([])
ws.append([])
ws.append(["Product", "Units"])
ws.append(["Widget", 420])
ws.append(["Gadget", 310])
for ref, value in {"F3": "Rep", "G3": "Quota", "F4": "Ana", "G4": 50000,
                   "F5": "Ben", "G5": 45000}.items():
    ws[ref] = value
wb.save("summary.xlsx")

Step 1 — Read the sheet without a header

The first move is always the same: stop pandas guessing. header=None returns every cell exactly where it sits, with integer row and column labels that map straight back to the worksheet (row label 0 is Excel row 1, column label 0 is column A).

Python
import pandas as pd

raw = pd.read_excel("summary.xlsx", sheet_name="Summary", header=None)
print(raw.shape)          # (11, 7)
print(raw.iloc[:6, :3])

Everything that follows is plain DataFrame slicing on raw. Nothing is re-read from disk, so you can try the split several ways without paying for the parse again. If the worksheet is large, add dtype=object so pandas does not coerce a column that mixes a header string with numbers below it — though for small summary sheets the default is fine because each block is re-typed at the end.

Step 2 — Split stacked tables on blank rows

When tables are stacked vertically, a row where every cell is empty marks the gap. A cumulative sum over that boolean gives each block its own number, and groupby hands the blocks back one at a time.

Python
def split_blocks(raw: pd.DataFrame, min_rows: int = 2) -> list[pd.DataFrame]:
    """Split a header=None frame into the tables separated by blank rows."""
    blank = raw.isna().all(axis=1)
    block_id = blank.cumsum()
    tables = []
    for _, block in raw[~blank].groupby(block_id[~blank]):
        block = block.dropna(axis=1, how="all")
        if len(block) < min_rows:
            continue                      # a title line, not a table
        header, body = block.iloc[0], block.iloc[1:]
        table = (
            body.set_axis(header.astype(str).str.strip(), axis=1)
                .rename_axis(None, axis=1)
                .reset_index(drop=True)
        )
        tables.append(table)
    return tables

left = raw.iloc[:, :3]
for table in split_blocks(left):
    print(table, end="\n\n")

Three details carry the weight here. dropna(axis=1, how="all") trims the empty columns that belong to a different table, so the product block comes back two columns wide rather than three. min_rows=2 throws away single-line blocks such as the title in row 1, which would otherwise become a table with a header and no body. And rename_axis(None, axis=1) removes the leftover row number that set_axis would otherwise print above the column names.

Turning blank rows into table boundaries Mark rows where every cell is empty, take a cumulative sum to number the blocks, group by that number, then promote each block first row to the header. blank rows isna().all(axis=1) block ids blank.cumsum() group groupby(block_id) promote header first row → columns each blank row bumps the counter, so every table gets its own id

Step 3 — Handle tables that sit side by side

The rep quota table in columns F:G shares rows with the region table, so a blank-row split alone merges them. Split on blank columns first, then split each vertical strip on blank rows. The logic is the same cumulative-sum trick turned ninety degrees.

Python
def split_columns(raw: pd.DataFrame) -> list[pd.DataFrame]:
    blank_col = raw.isna().all(axis=0)
    col_id = blank_col.cumsum()
    return [raw.loc[:, ~blank_col & (col_id == g)] for g in col_id[~blank_col].unique()]

tables = []
for strip in split_columns(raw):
    tables.extend(split_blocks(strip))

for table in tables:
    print(table.columns.tolist(), len(table))
# ['Region', 'Revenue', 'Deals'] 3
# ['Product', 'Units'] 2
# ['Rep', 'Quota'] 2

This breaks down in exactly one situation: a gap column that is not fully empty, because a note or a stray total was typed into it further down the sheet. When that happens the strips never separate, and the anchor approach in the next step is the more robust choice.

Step 4 — Find a table by its header label instead

Blank-line splitting depends on the layout staying tidy. An anchor search depends only on a header label existing somewhere on the sheet, which is usually the one thing the person maintaining the file never changes. Find the cell, then extend right and down until the first empty cell.

Python
import numpy as np

def table_at_anchor(raw: pd.DataFrame, label: str) -> pd.DataFrame:
    hits = np.argwhere(raw.eq(label).to_numpy())
    if not len(hits):
        raise LookupError(f"no cell contains {label!r}")
    r, c = hits[0]
    region = raw.iloc[r:, c:]
    first_col = region.iloc[:, 0].isna().to_numpy()
    height = first_col.argmax() if first_col.any() else len(region)
    header_row = region.iloc[0].isna().to_numpy()
    width = header_row.argmax() if header_row.any() else region.shape[1]
    block = region.iloc[:height, :width]
    return block.iloc[1:].set_axis(block.iloc[0], axis=1).rename_axis(None, axis=1).reset_index(drop=True)

sales = table_at_anchor(raw, "Region")
quota = table_at_anchor(raw, "Rep")
products = table_at_anchor(raw, "Product")
print(sales, quota, products, sep="\n\n")

The height comes from the first column only, which is deliberate: a key column like Region is never blank inside a table, while a numeric column might legitimately have gaps. If your anchor column can be empty mid-table, pass a different column index to measure the height from.

Three ways to locate a table on a shared sheet Blank-line splitting needs a tidy layout, anchor search needs a stable header label, and named Excel tables need the author to have created a table object. split on blanks no labels needed finds every table breaks on stray notes anchor label survives moved blocks one table per call needs a stable header named Excel table exact range stored no guessing at all only if author made one prefer the named table when it exists, the anchor when it does not

Step 5 — Read named Excel tables directly

If whoever built the workbook used Insert → Table, the ranges are already recorded in the file and there is nothing to infer. openpyxl exposes them on each worksheet through ws.tables, which maps the table name to its A1:C4-style reference. Note that this requires a normal (not read_only) load, because read-only worksheets do not parse table parts.

Python
from openpyxl import load_workbook

def read_named_tables(path: str) -> dict[str, pd.DataFrame]:
    wb = load_workbook(path, data_only=True)
    out = {}
    for ws in wb.worksheets:
        for name, ref in ws.tables.items():
            rows = [[cell.value for cell in row] for row in ws[ref]]
            out[name] = pd.DataFrame(rows[1:], columns=rows[0])
    return out

tables = read_named_tables("summary.xlsx")
print(list(tables))       # [] for this file — no Insert → Table was used

data_only=True returns the cached results of any formulas inside the table rather than the formula strings. The full treatment of this route, including totals rows and header options, is in Read an Excel Table by Name into pandas.

Step 6 — Restore the types

Because the sheet was read with header=None, each column initially mixed a header string with the values under it, and pandas typed it accordingly. Once the header has been promoted the column holds only data, so a single conversion puts the numbers back.

Python
sales = sales.astype({"Revenue": "int64", "Deals": "int64"})
quota = quota.assign(Quota=pd.to_numeric(quota["Quota"], errors="coerce"))
print(sales.dtypes)

Use astype when you are sure the column is clean and want a loud failure if it is not, and pd.to_numeric(..., errors="coerce") when a stray "n/a" should become NaN instead. The second is the safer default for a hand-maintained sheet; Convert Excel Text Columns to Numbers with pandas walks through the cases where it matters.

Common pitfalls

SymptomCauseFix
Two tables come back mergedThe gap row holds a space or an invisible formatted valueRun raw.replace(r"^\s*$", np.nan, regex=True) before splitting
Title becomes a one-row tablemin_rows too lowKeep min_rows=2 or higher
Header row reads Unnamed: 0header=None was not passedAlways read the whole sheet raw first
Anchor finds the wrong cellThe label also appears in a note aboveSearch a narrower slice, or match the second occurrence
Numbers read as text after the splitColumn typed while it held the headerRe-type each table as in Step 6
Totals row included in the tableThe block has no blank row before the totalFilter it out, as in Remove Subtotal and Total Rows from an Excel Export

A cell that looks empty but is not is the most common reason a split fails. Excel keeps a cell alive once it has been formatted, and a cell containing a single space is indistinguishable on screen from an empty one. Normalising whitespace to NaN before you compute blank removes the whole class of problem.

Performance and scale notes

Everything here happens in memory after a single read, so the cost is the parse, not the splitting. A summary sheet of a few hundred cells takes milliseconds either way. For a large worksheet, pass usecols="A:G" and nrows to read_excel so pandas never parses the thousands of unrelated rows below the area you care about, or switch the engine to calamine as described in Speed Up pandas Excel Reads with the calamine Engine.

Where the time goes when extracting tables from a sheet Parsing the workbook dominates. Splitting on blank rows and columns and searching for anchors are small in-memory operations by comparison. relative cost on a 50,000-cell sheet read_excel parse dominant blank-line split in memory anchor search in memory re-typing columns negligible trim what you parse; the splitting itself is cheap

If the same workbook is processed on every run, read the sheet once, split it, and write each table to its own Parquet or CSV file. Downstream steps then load a clean, typed table directly rather than repeating the inference.

Conclusion

Read the sheet with header=None, then decide how to find the edges. Blank rows and columns work when the layout is tidy; an anchor label works when the blocks move around but their headers stay put; and a named Excel table, when one exists, removes the guesswork entirely. Whichever route you take, re-type each table after its header is promoted, and normalise whitespace first so that invisible content never glues two tables together.

Frequently asked questions

Can read_excel read a specific range like B3:D6 directly? Not by cell reference, but usecols="B:D" with skiprows=2 and nrows=4 selects the same rectangle. That works for a fixed layout; the splitting approaches on this page are for layouts that shift.

How do I know how many tables a sheet contains? Run the blank-row and blank-column split and count the blocks it returns. If the count changes between files, log it — a new or missing block usually means the workbook layout has changed.

What if the tables have no blank row between them? Then the only boundary is the header itself. Use the anchor search, giving the second table's header label explicitly, and cut the first table at the row where that label appears.

Does this work with .xls and .ods files? Yes. Everything after the read is pandas slicing, so only the engine changes: xlrd for .xls and odf for .ods. The named-table route needs openpyxl and therefore .xlsx or .xlsm.

More on messy spreadsheets

More on messy spreadsheets →