Skip to content

XLSX Tabs → One Long Table (sheet per period)

View on GitHub

What

Flatten an Excel workbook that keeps one tab per period (January / February / March) into a single long table with a month column.

Synthetic / teaching example

The data here is constructed, not sourced — sales_q1.xlsx has three same-shape sheets (region, units). The problem class — reports split across monthly/topic tabs — is universal; the rows are not.

Why interesting

"A tab per month" is how most business workbooks grow, and it makes the data un-analysable: you can't filter or chart across tabs without copy-pasting them together by hand. Pulling every sheet into one normalised table, stamped with which tab it came from, is the routine first step — and one most CSV tools can't do because they don't read .xlsx at all.

flowchart LR
    X["sales_q1.xlsx<br/><small>3 tabs · region · units</small>"]
    X -->|"xlsx_sheet: January"| J["1 · month_jan<br/><small>+month · _1jan</small>"]
    X -->|"xlsx_sheet: February"| F["2 · month_feb<br/><small>+month · _2feb</small>"]
    X -->|"xlsx_sheet: March"| M["3 · month_mar<br/><small>+month · _3mar</small>"]
    J --> C["merge_months<br/><small>combined_output</small>"]
    F --> C
    M --> C
    C --> R["1-merge_months-combined.csvx"]

Problem class documented in. (sources for the problem class — not for the data)

  • Tab-per-period workbooks are the archetypal "spreadsheet that should have been a table" — the motivation behind tidyxl/unpivotr (R) and countless "combine all sheets" macros.

The trick — extract each tab, stamp it, then fan in

Four templates, run all at once with bxp-cli --config ./sample.json. January is shown below; February and March are the same three lines with a different sheet name. Both intermediate files are committed and pinned by goldens, so this is what the run really writes.

Pass 1 · month_jan — pull one sheet out of the workbook

An xlsx_sheet block selects one sheet by name and extracts it to a plain CSV named by its output_suffix. This is the step that most CSV tools cannot do at all, because they never open .xlsx in the first place — and it is the only place you get to see what is actually inside the workbook:

sales_q1_jan.sheet.csv — the January tab, extracted
region,units
Praha,120
Brno,95

The sheet knows its own month only by being called "January". That fact is in the tab name, not in any cell — which is exactly what the next step fixes.

Pass 2 · the same template — stamp the month, number the part

The template's input_schema adds a month literal, so the month survives into the data where a GROUP BY can reach it. The output name is numbered (_1jan, _2feb, _3mar) purely so the fan-in stacks the months in calendar order rather than alphabetical:

sales_q1_1jan.part.csv
month,region,units
January,Praha,120
January,Brno,95

Pass 3 · merge_months — fan in

combined_output: true over *.part.csv stacks the three numbered parts into one long table — Final result below.

Final result

Three Excel tabs become one table, in calendar order, with the source month as a column:

month,region,units
January,Praha,120
January,Brno,95
February,Praha,110
February,Brno,130
March,Praha,140
March,Brno,105

Sample data

Run it with bxp-cli --config ./sample.json — the commented template selects one sheet per month, then a fan-in pass merges them:

{
  // Teaching example — synthetic data. A spreadsheet with one TAB PER MONTH
  // (January / February / March), each tab the same shape (region, units). You
  // want one long table with a `month` column instead of three disconnected tabs.
  //
  // An `xlsx_sheet` block selects ONE sheet by name (prefix match) and extracts
  // it to an intermediate CSV. So there is one template per month, each stamping
  // its own `month` literal; a final fan-in pass (`combined_output`) stacks the
  // three into one table. Run it all with:  bxp-cli --config ./sample.json
  //
  // Glob safety: each month writes "<stem>_<mon>.part.csv"; the fan-in reads
  // ".part.csv" only, and never re-reads the extracted "_<mon>.csv" or its own
  // combined ".csvx".
  conversion_templates: {

    month_jan: {
      data_dir:         ".",
      xlsx_sheet:       { name: "January", header_row: 1, output_suffix: "_jan.sheet.csv" },
      file_pattern_in:  "_jan.sheet.csv",
      file_pattern_out: "_1jan.part.csv",
      input_schema: { $month: "'January'", $region: "[region]", $units: "[units] * 1" },
      row_rules: [ { when: "1 = 1", rows: [ {} ] } ],
      output_schema: { month: "$month", region: "$region", units: "$units" }
    },

    month_feb: {
      data_dir:         ".",
      xlsx_sheet:       { name: "February", header_row: 1, output_suffix: "_feb.sheet.csv" },
      file_pattern_in:  "_feb.sheet.csv",
      file_pattern_out: "_2feb.part.csv",
      input_schema: { $month: "'February'", $region: "[region]", $units: "[units] * 1" },
      row_rules: [ { when: "1 = 1", rows: [ {} ] } ],
      output_schema: { month: "$month", region: "$region", units: "$units" }
    },

    month_mar: {
      data_dir:         ".",
      xlsx_sheet:       { name: "March", header_row: 1, output_suffix: "_mar.sheet.csv" },
      file_pattern_in:  "_mar.sheet.csv",
      file_pattern_out: "_3mar.part.csv",
      input_schema: { $month: "'March'", $region: "[region]", $units: "[units] * 1" },
      row_rules: [ { when: "1 = 1", rows: [ {} ] } ],
      output_schema: { month: "$month", region: "$region", units: "$units" }
    },

    // FAN-IN — stack the three per-month parts into one long table.
    merge_months: {
      data_dir:         ".",
      file_pattern_in:  ".part.csv",
      file_pattern_out: ".csvx",
      combined_output:  true,
      input_schema: { $month: "[month]", $region: "[region]", $units: "[units]" },
      row_rules: [ { when: "1 = 1", rows: [ {} ] } ],
      output_schema: { month: "$month", region: "$region", units: "$units" }
    }
  }
}
month,region,units
January,Praha,120
January,Brno,95
February,Praha,110
February,Brno,130
March,Praha,140
March,Brno,105

The input is a binary file — sales_q1.xlsx on GitHub.