A pivot table turns rows into columns: one row per item, one column per month,
totals in the cells. DuckDB has a PIVOT statement that does it in one query.
It also has a constraint that lesson 6 introduced in a smaller form — some parts
of a query must be known before values exist — and this is that rule at its most
uncomfortable. Checkpoint:
engine-08.
A result whose shape is data
Every query so far returned a fixed set of columns. This one cannot. Filter to January and you want one month column; filter to the full range and you want six. The shape of the result depends on the values in it.
That is why PivotResult carries its own headers:
/** Item × month pivot for the table's summary view. */
export interface PivotResult {
columns: string[];
rows: (string | number)[][];
}
Not an array of objects. The consumer cannot know the keys in advance, so it gets a header row and positional cells — the same reason a CSV has a header line.
The statement
PIVOT (SELECT strftime(date, '%Y-%m') AS month, item, amount::DOUBLE AS amount
FROM sales WHERE date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE))
ON month IN ('2025-01','2025-02','2025-03','2025-04','2025-05','2025-06')
USING SUM(amount) GROUP BY item ORDER BY item
Read it in three parts. The parenthesised subquery is the source, with
strftime reducing each date to a YYYY-MM string. ON month IN (…) names the
values that become columns. USING SUM(amount) GROUP BY item gives one row per
item, with each cell the sum for that column’s month.
amount::DOUBLE is the same cast as the previous lesson, for the same reason:
without it the sums arrive as decimal objects rather than numbers.
The month list cannot be bound
Now the uncomfortable part. That IN (…) list is written into the SQL text. The
dates on the line above it are bound parameters. Both come from the same filter,
and they are treated completely differently.
The reason is the one from lesson 6, one level up. A prepared statement is planned before its values arrive, and here the output columns depend on that list. The planner cannot describe the result of a query when it does not know how many columns it has. Bound values arrive after planning, so they are unavailable to a decision that has to happen during it.
So the list is generated:
/** Months spanned by an ISO date range, as 'YYYY-MM'. */
function monthsBetween(startISO: string, endISO: string): string[] {
const months: string[] = [];
const cursor = new Date(`${startISO}T00:00:00Z`);
const end = new Date(`${endISO}T00:00:00Z`);
cursor.setUTCDate(1);
while (cursor <= end && months.length < 120) {
months.push(cursor.toISOString().slice(0, 7));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return months;
}
And this is where the reasoning has to be explicit rather than hand-waved. The
strings that reach the SQL text are not user input. They are produced by this
function, from two ISO dates, in a fixed YYYY-MM format that
toISOString().slice(0, 7) cannot deviate from. The dates themselves are still
bound in the WHERE clause; only their derived month labels are interpolated.
That is the whole safety argument, and notice its shape: when a value must
become structure, derive it from something you control rather than passing it
through. The user chooses a range; you choose the labels. The 120 bound is
the same instinct — a malformed range should not generate a query with ten
thousand columns.
Everything is UTC on purpose. new Date('2025-03-01') parses as UTC midnight,
which in a negative-offset timezone is the last day of February locally. Pin the
timezone or setUTCDate(1) walks you into the wrong month for users west of
Greenwich, which is a bug you will not see and they will.
Missing cells are zero, not null
An item with no sales in a month has no row to sum, and PIVOT produces NULL
there. Turning that into 0 happens at the boundary:
return {
columns: ['Item', ...months],
rows: rows.map(r => [
String(r.item),
...months.map(m => (r[m] == null ? 0 : Number(r[m]))),
]),
};
Reading each row by month key rather than trusting column order is deliberate —
it means the returned columns array and the cells cannot drift apart. And
== null catches both null and undefined, which is the one place loose
equality is the right tool.
Whether missing should be zero is a judgment call. Here every cell is a sum of
sales and no sales genuinely means zero, so the table stays numeric and
alignable. For an average, zero would be a lie and null should survive to the
renderer.
Build it
| File | Action | What goes in it |
|---|---|---|
src/types.ts |
modify | PivotResult; add pivotByMonth to DbWorkerApi |
src/db-worker.ts |
modify | monthsBetween and pivotByMonth, with the comment explaining why the month list is interpolated; expose it |
index.html |
modify | A pivot table whose header row is filled at runtime |
src/index.ts |
modify | Build the header from columns, fill the body from rows |
Done when: the pivot shows eight item rows and six month columns, and each row’s cells sum to that item’s total from the previous lesson’s totals table.
Answer key: engine-07...engine-08.
Challenge: reconcile it yourself
Add a row-total column in JavaScript and compare each against
aggregateForChart’s number for that item. They must match exactly — a pivot is
a rearrangement, not a recalculation, so any discrepancy means a cell was
dropped or double-counted. Then narrow the date filter to a single month and
confirm the table collapses to one column without any other code changing.
Next lesson: hand the whole engine to the user — free-form SQL, real error messages, and query plans.