A minimal example makes it obvious: merge the header A1:D1 and add another merge at B5:C5.
Reading the xlsx back:
('2026-11 revenue', None, None, None)
('Date', 'Item', 'Amount', 'Note')
('2026-11-01', 'Cake', 1280, None)
(None, None, None, 'subtotal above')
(None, 'merged note', None, None)
('2026-11-02', 'Loose tea', 960, None)The same content as csv, read back:
row0: '2026-11 revenue,,,' row1: 'Date,Item,Amount,Note' row2: '2026-11-01,Cake,1280,' row3: ',,,subtotal above' row4: ',merged note,,' row5: '2026-11-02,Loose tea,960,'
Where they differ
| xlsx | csv | |
|---|---|---|
| Merge information | merged_cells.ranges tells you which cells are one cell | Lost entirely, becoming empty fields |
| Non-top-left cell | Reads as None (the value lives only top-left) | Empty string, indistinguishable from genuinely unfilled |
| Empty string versus empty cell | Both read as None | Both are adjacent commas |
| Recovering header hierarchy | Yes, inferred from merged ranges | No; you see one line of text and three blanks |
The second row is the killer: in csv, 'merged note' looks like it sits in column B with A and C empty. You cannot tell whether it was a merged centred caption or a real B value with neighbours blank. In xlsx you just look at the merged ranges.
Practical trade-off
- Structure matters (header tiers, group subtotals): send xlsx and require it to report merged ranges first.
- Only data rows (a clean two-dimensional table): csv is cheaper in tokens and carries no merge ambiguity.
- Unsure: send xlsx for a structure description, then convert to csv for the data work. Two steps beats one.