Skip to content
Beta · version 0.6.1 · commands and the JSON format may still change.

Inspect Excel workbooks

A workbook can hold several sheets and several tables. Use tabalyst inspect to see how Tabalyst understands an .xlsx or .xlsm workbook before you scan it, and to choose the table when Tabalyst cannot. Tabalyst Inspect reads the workbook once, lists its candidate tables, proposes one and writes the answer in a small JSON file beside the source:

Terminal window
tabalyst inspect sales.xlsx
Inspect: sales.xlsx-inspect.json
Selection: $.Orders (much larger than $.Regions)

The Inspect file is named after the full source name plus -inspect.json: sales.xlsx-inspect.json. Its last section, config, holds the rules that tabalyst scan and tabalyst report apply to this workbook: the table, and optionally its header row. The other sections describe what Inspect found; Tabalyst replaces them each time it inspects. The structure of the file is in the Inspect format.

Tabalyst reads .xlsx and .xlsm files (macros are ignored). It never modifies the workbook. Older .xls files, .xlsb and .ods files are refused with exit code 2: save them as .xlsx first.

A workbook with one sheet holding one table needs no Inspect file. tabalyst scan and tabalyst report inspect it themselves, keep the result in Tabalyst’s local storage and read the table it selects:

Terminal window
tabalyst report sales.xlsx

Run tabalyst inspect when you want to see or change the rules, or when Inspect cannot choose.

A candidate is a sheet, or a named table (an Excel table, also called a list object) inside a sheet. Candidates are written as paths with the workbook as root:

PathMeaning
$.SalesThe sheet Sales.
$.Sales.OrdersThe named table Orders of the sheet Sales.
$["Q1 2026"]A sheet whose name is not a plain identifier is quoted.

A sheet that holds named tables is not a candidate itself: its tables are. The author declared them, and a table guessed beside them would duplicate or contradict them. Inspect lists such a sheet as not eligible (has_tables).

For a sheet, Inspect looks for the header row in its first 50 rows: the first row that fills at least half the width of the sheet, holds only text, repeats few names and is followed by data. Title lines and blank rows above the header are skipped, and the table starts at the first and ends at the last filled column of the header. The data rows are the filled rows under it; blank rows are not records. A named table gives its range and header exactly, with no guessing.

ineligible_reasonA candidate is not eligible when
no_cellsThe sheet is empty, or is a chart sheet.
no_headerNo row looks like a header followed by data: a note, a block of numbers, a header row alone.
no_data_rowsA named table has no data row.
has_tablesThe sheet holds named tables, which are listed on their own.

Inspect selects a table only when that choice is clear, with the rule that it uses for JSON files: only one table is eligible, or the largest one has at least 10 times more data rows than the next one. A hidden sheet is selected only when no visible table is eligible. Sheet names play no part. Otherwise nothing is selected, and scan and report stop with exit code 2 before analyzing anything:

Error [shop.xlsx]: 2 tables of shop.xlsx are equally plausible. Nothing was analyzed.
Candidates:
$.Sales (8 rows)
$.Costs (5 rows)
Pass --collection with a sheet or table path such as '$.Sheet', or run `tabalyst inspect shop.xlsx` and set config.structure.dataset_path in the file it writes.
Choose the table to analyze:
tabalyst report shop.xlsx --collection Sales
tabalyst report shop.xlsx --collection Costs
Or report every table: tabalyst report shop.xlsx --all-collections

report and scan print one ready-to-run command per table, and so does tabalyst inspect shop.xlsx: copy the one you want and run it. The commands use the short form, the name alone (--collection Costs, or --collection Sales.Orders for a named table), whenever the name is plain. A sheet name that is not a plain identifier needs the quoted path ('$["Q1 2026"]'), which the commands print as it must be typed. A workbook is read one table at a time: pass --collection once, or use tabalyst report --all-collections to report every table, one pair of files per table (see Report Excel workbooks). To make the choice stick, open shop.xlsx-inspect.json and set it in config:

"config": {
"structure": {"dataset_path": "$.Costs", "header_row": null}
}

tabalyst report shop.xlsx then analyzes $.Costs. --collection outranks the Inspect file, and a notice says so when they differ.

Everything you may edit is in config. Omit a key to keep the default:

KeyDefaultEffect
structure.dataset_paththe selection, or nullThe table analyzed: $.Sheet or $.Sheet.Table.
structure.header_rownullThe 1-based row of the sheet that holds the header, instead of the detected one. Use it when the detection picks the wrong row, or finds none.

header_row applies to a sheet; a named table has its own header. The row must hold at least one value. A sheet with no recognizable header makes scan and report stop with exit code 4 and say to set it. Unknown keys and values of the wrong type are errors that name the key, never ignored, and flatten, arrays and errors do not exist for a workbook.

Cells keep their Excel type: a number is a number, a boolean a boolean, a text a string. Excel has a single number type, so a whole number such as 21 is read as an integer, even in a column of amounts such as 10.5. Dates and times are read as ISO text (2026-01-31), which the date detector recognizes, so a date column is typed date. A formula is read as its last calculated value. An empty cell is a missing value, like an empty CSV cell.

Inspect warns about what a scan will have to live with:

CodeWhen
blocks_not_splitBlank rows lie among the data. Blocks separated by blank rows are not split into several tables; the blank rows are skipped.
duplicate_headers, blank_headersThe header repeats a name or leaves a cell empty. The columns keep their position, and the report names them id#1, id#2.
merged_cellsMerged ranges lie in the data: only the top-left cell holds a value.
multi_level_headerHeader cells are merged, as in a header on two levels. Only the last row is read as the header.

Merged cells above the header, such as a title, are ignored. The informative candidate_not_eligible entries explain why a sheet was left out.

Run tabalyst inspect again after the workbook changes. The detection and warnings are rewritten; your config is kept. --reset-config replaces it by the detected one, and --force replaces a file that cannot be kept, such as one written for another version of the format, one with an invalid config, or an Inspect file written for a JSON source. scan and report refuse such a file of another kind, with exit code 2, instead of reading it with the wrong rules. If the configured sheet or table is no longer in the workbook, the new file warns (configured_path_not_found), and scan and report stop with exit code 2 and list the sheets that exist.

A new Inspect file starts from the layers below it: scan.excel.header_row of a --config file is written into its config, so the file does not undo what the configuration asked for.

From lowest to highest priority:

  1. Tabalyst defaults;
  2. the table Inspect detected, when the workbook has no Inspect file;
  3. the scan.excel section of each --config file (dataset_path, header_row);
  4. the config of the Inspect file beside the workbook;
  5. --collection.
  • A pattern such as *.xlsx skips the lock files that Excel keeps beside an open workbook (~$book.xlsx), as it skips Inspect files.

  • One table per run. A workbook is not scanned sheet by sheet, and relations between sheets are not analyzed.

  • Blocks separated by blank rows, and total or note rows under the data, are not recognized: a total row counts as a record.

  • A real error cell (#DIV/0!) and a formula that was never calculated read as empty cells, and cannot be counted.

  • Calamine reads a sheet into memory, about 0.9 byte per byte of its XML. A workbook with a sheet whose XML exceeds 1 GiB is refused. Reading a sheet of a million rows of eight columns takes about 4 seconds and 360 MB.

  • Password-protected workbooks are refused with exit code 4.

--verbose lists every candidate and the informative warnings; --quiet keeps only warnings and errors. Messages use standard error.

CodeMeaning
0The inspection worked, including when it selected nothing.
2Configuration problem: unsupported spreadsheet format, invalid Inspect file, unresolved table, or a configured sheet that is gone (in scan and report).
4The workbook cannot be read: corrupt, password-protected, empty, too large, or no header in the sheet. No file is written.
1Another failure, such as an output that cannot be written.
import tabalyst
result = tabalyst.inspect("sales.xlsx")
print(result.path)
print(result.document.detection.selection.path)

tabalyst.inspect() and tabalyst.generate_inspections() accept workbooks as they accept JSON files, with config_path, reset_config and force. tabalyst.scan() reads the table named by ScanConfig(excel={"dataset_path": "$.Sales"}); without it, a workbook is an error that says to choose.