Your AI Probably Can't Build a Pivot Table — Here's What Can
AI agents get asked to build Excel reports constantly. When a Python-based agent gets that request, it almost always reaches for the same library: openpyxl. So I ran a real test: give the same task to (a) a generic Python/openpyxl session and (b) an agent using Kookerella.FsOpenXmlDsl’s MCP server, and look at what actually landed in the resulting file - not what either session claimed it did.
Everything below is real: real code, run for real, with the resulting .xlsx files inspected
by unzipping them (an .xlsx is just a zip of XML parts) to check what’s objectively inside,
rather than trusting either tool’s own success message.
The task
Given a raw sales workbook (Region, Product, Quarter, Revenue), add a pivot table summarizing revenue by region and quarter, and add sparklines showing the revenue trend per product row.
Both arms start from the identical input: 48 rows of plain, unstyled sales data across 4 regions, 3 products, and 4 quarters.
Arm A: a generic AI + openpyxl session
This is the realistic path - not a strawman. openpyxl does have a pivot module and a
Worksheet.add_pivot method, so the honest first step is to check whether they’re actually
usable for creating a new pivot table, not just preserving one that’s already there.
>>> from openpyxl.worksheet.worksheet import Worksheet
>>> import inspect
>>> inspect.getsource(Worksheet.add_pivot)
def add_pivot(self, pivot):
self._pivots.append(pivot)
add_pivot doesn’t build anything - it just appends a pre-built TableDefinition object
you’re expected to construct entirely yourself. And that object’s constructor is 80+
raw XML-mirroring parameters (pivotFields, rowFields, dataFields, cache relationships,
GUIDs, …) with zero grouping or aggregation help. You’d have to compute every cache record
and field index by hand, keeping several deeply-nested object graphs in exact agreement.
That’s not a realistic thing to hand-roll for a normal “add a pivot table” request.
So the actual realistic move - what a competent AI session does in practice - is: compute
the aggregation with pandas, then write the result into the sheet as a plain static
table that’s styled to look like a pivot table:
import pandas as pd
import openpyxl
from openpyxl.utils.dataframe import dataframe_to_rows
df = pd.read_excel("input.xlsx", sheet_name="RawSales")
pivot = pd.pivot_table(df, values="Revenue", index="Region", columns="Quarter", aggfunc="sum")
pivot = pivot.reset_index()
wb = openpyxl.load_workbook("input.xlsx")
ws = wb.create_sheet("Pivot Summary")
for row in dataframe_to_rows(pivot, index=False, header=True):
ws.append(row)
For sparklines, there’s nothing to even attempt - openpyxl ships zero sparkline classes:
>>> import pkgutil, openpyxl
>>> [m.name for m in pkgutil.walk_packages(openpyxl.__path__, prefix="openpyxl.") if "spark" in m.name]
[]
What’s objectively in the resulting file
Unzipping arm_a_openpyxl_result.xlsx and checking for the actual OOXML parts a real pivot
table or sparkline requires:
pivotCache parts: []
pivotTable parts: []
sparkline-mentioning parts: []
Nothing. The “Pivot Summary” sheet is a plain grid of numbers - it looks right at a glance, but it isn’t a real Excel PivotTable: it can’t be refreshed, re-arranged, or drilled into by whoever opens the file. There’s no sparkline anywhere, because there’s no way to make one.
There’s a second, independent piece of evidence worth including here. When you open the other arm’s result (below, the one with real sparklines) back up in openpyxl, it says this, unprompted:
UserWarning: Sparkline Group extension is not supported and will be removed
openpyxl’s own warning, not my claim.
Arm B: the same task via Kookerella.FsOpenXmlDsl
Here’s the important bit: Arm A was a Python session, so Arm B has to be too - otherwise this isn’t a fair comparison of what a Python-based AI agent can reach for, it’s just “F# is more capable than Python,” which isn’t the point and isn’t news.
Kookerella.FsOpenXmlDsl.Mcp ships a plain CLI, fsopenxmldsl-mcp build <input.json> <output.xlsx>, that runs the exact same code its create_workbook_from_json MCP tool does.
A Python (or any-language) agent never touches F# at all - it authors JSON matching the
library’s own schema and hands it to that one command. Here’s the pivot table and sparkline
group from the actual JSON payload used below:
"pivotTables": [
{
"sourceSheet": "RawSales",
"sourceTopLeft": "A1",
"sourceBottomRight": "D49",
"rowField": "Region",
"columnField": "Quarter",
"valueField": "Revenue",
"aggregation": "sum",
"valueCaption": "Total Revenue",
"anchorTopLeft": "A1"
}
]
"sparklineGroups": [
{
"style": { "type": "line", "showHigh": true, "showLow": true },
"sparklines": [
{ "cell": "F2", "dataTopLeft": "B2", "dataBottomRight": "E2" },
{ "cell": "F3", "dataTopLeft": "B3", "dataBottomRight": "E3" },
{ "cell": "F4", "dataTopLeft": "B4", "dataBottomRight": "E4" }
]
}
]
No field indices, no cache records, no GUIDs - just the row/column/value fields and the source range, the same three inputs you’d name out loud if you were describing the pivot table to a person. Building it is one command, runnable from any shell:
fsopenxmldsl-mcp build payload.json report.xlsx
That’s genuinely it. No F# was written to produce this result - the library’s own
Json.toWorkbook parses the payload into the same internal model an F# caller would build
directly, and the same Writer that performs the real aggregation runs regardless of which
language produced the input.
What’s objectively in the resulting file
Same check as Arm A, run against report.xlsx - the file the plain build command
produced from nothing but that JSON payload:
pivotCache parts: ['pivotCache/pivotCacheDefinition1.xml',
'pivotCache/pivotCacheRecords1.xml',
'pivotCache/_rels/pivotCacheDefinition1.xml.rels']
pivotTable parts: ['xl/pivotTables/pivotTable.xml',
'xl/pivotTables/_rels/pivotTable.xml.rels']
sheet3.xml contains extLst/sparkline: True True
A real pivot cache, a real pivot table definition, and a real sparkline extension in the worksheet XML. Reading the computed grid back confirms the numbers are correct, grand totals included:
('Region', 'Q1', 'Q2', 'Q3', 'Q4', 'Grand Total')
('East', 31726, 33667, 38686, 37879, 141958)
('North', 47807, 44610, 52735, 44762, 189914)
('South', 40388, 50177, 54734, 38006, 183305)
('West', 31927, 35795, 36407, 44050, 148179)
('Grand Total', 151848, 164249, 182562, 164697, 663356)
And the sparkline XML itself, referencing exactly the right cells for each product row:
<x14:sparklineGroup type="line" high="1" low="1">
<x14:sparklines>
<x14:sparkline>
<xne:f>B2:E2</xne:f>
<xne:sqref>F2</xne:sqref>
</x14:sparkline>
...
Why this happens
It’s not that openpyxl is a bad library - it’s a broad, general-purpose tool, and general tools tend to cover the common 80% (cell values, basic formatting, charts) well. Pivot tables and sparklines sit in the harder 20%: a pivot table’s file format bakes in the result of an aggregation in three places that all have to agree (the cache, the table definition, and the literal computed grid), and sparklines live in a Microsoft extension block most general tooling never got around to supporting at all.
An AI agent reaching for a general-purpose library inherits whichever 20% that library didn’t cover - usually silently, since a static lookalike table doesn’t throw an error, it just isn’t what was actually asked for.
Try it
The MCP server behind Arm B is Kookerella.FsOpenXmlDsl.Mcp - install it, point your MCP client at the one command it installs, and it’s ready:
dotnet tool install -g Kookerella.FsOpenXmlDsl.Mcp
{
"mcpServers": {
"fsopenxmldsl": {
"command": "fsopenxmldsl-mcp"
}
}
}
It’s also Glama-listed if your client discovers servers that way. It exposes the same capability as plain JSON/XML too - so even a Python-based agent that never touches .NET directly can drive it, and still get a real pivot table and real sparklines back. See the product page for the full tool list.