Skip to content

feat(sql): support PIVOT keyword for rotating rows to columns#5313

Merged
bluestreak01 merged 25 commits intomasterfrom
feat-pivot
Jan 6, 2026
Merged

feat(sql): support PIVOT keyword for rotating rows to columns#5313
bluestreak01 merged 25 commits intomasterfrom
feat-pivot

Conversation

@nwoolmer
Copy link
Copy Markdown
Contributor

@nwoolmer nwoolmer commented Jan 13, 2025

Preamble

Fixes #997

Depends on #5435
Depends on #5408

Summary

This PR introduces the new PIVOT keyword.

In simple terms, PIVOT is a specialised GROUP BY query, that helps to group up a selection of rows into columns - essentially, pivoting from a narrow schema, to a wide schema.

Let's take our example trades table:

CREATE TABLE 'trades' ( 
	symbol SYMBOL CAPACITY 256 CACHE,
	side SYMBOL CAPACITY 256 CACHE,
	price DOUBLE,
	amount DOUBLE,
	timestamp TIMESTAMP
) timestamp(timestamp) PARTITION BY DAY WAL;

In this table, we have some crypto trading data.

Let's say we wanted to get the latest buy and sell prices for some tickers. We could write a group by query:

SELECT side, symbol, last(price) AS price 
FROM trades
WHERE symbol IN ('BTC-USD', 'ETH-USD')
ORDER BY side, symbol;

Which gives:

side symbol price
buy BTC-USD 83200
buy ETH-USD 1834.6
sell BTC-USD 83199.9
sell ETH-USD 1834.4

But what if we want to have a wide schema, with a column per symbol? PIVOT solves this problem, by performing a combined filter plus aggregation on rows, and mapping the values to columns.

For example:

trades
PIVOT (
  last(price)
  FOR symbol IN ('BTC-USD', 'ETH-USD')
  GROUP BY side
  ORDER BY side
);

This query performs a similar GROUP BY to the first query, a GROUP BY side, symbol with appropriate filtering. Then, a second aggregation is performed, along with per-column filtering using CASE expressions, to create the final columns.

Here is the result:

side BTC-USD ETH-USD
buy 83200 1834.6
sell 83199.9 1834.4

We can now compare the plans for the two queries.

Original query:

Sort light
  keys: [side, symbol]
    Async JIT Group By workers: 16
      keys: [side,symbol]
      values: [last(price)]
      filter: symbol in [BTC-USD,ETH-USD]
        PageFrame
            Row forward scan
            Frame forward scan on: trades

PIVOT query:

Sort light
  keys: [side]
    GroupBy vectorized: false
      keys: [side]
      values: [last_not_null(case([last,NaN,symbol])),last_not_null(case([last,NaN,symbol]))]
        Async JIT Group By workers: 16
          keys: [side,symbol]
          values: [last(price)]
          filter: symbol in [BTC-USD,ETH-USD]
            PageFrame
                Row forward scan
                Frame forward scan on: trades

Observe that the first part of the query, the Async JIT Group By is in fact, identical, for both queries. Only the final step changes, wherein the PIVOT query additionally pivots the data.

What about UNPIVOT? Not all PIVOT queries are reversible, but UNPIVOT can reverse simple ones. For example:

(
  trades
  PIVOT (
    last(price)
    FOR symbol IN ('BTC-USD', 'ETH-USD')
    GROUP BY side
  )
) UNPIVOT (
  price
  FOR symbol IN ('BTC-USD', 'ETH-USD')
);

gives:

side symbol price
buy BTC-USD 83200
buy ETH-USD 1834.6
sell BTC-USD 83199.9
sell ETH-USD 1834.4

Changelist:

Required

  • Support SQL Standard PIVOT syntax
    • PIVOT columns
      • single column
      • multiple columns
      • per-column aliasing
    • FOR-IN
      • support single expr
      • support multiple exprs
    • GROUP BY
    • ORDER BY
    • LIMIT
    • SAMPLE BY -> supported using a subquery
  • Support SQL Standard UNPIVOT syntax now in feat(sql): support UNPIVOT keyword for rotating columns to rows #5732
  • Bugfixes
    • Make sure CTEs require no extra brackets for PIVOT to work
    • Memory leak in testPivotUnpivotRoundTrip
    • Fix parser bug - ')' missing, when ORDER BY expr used in PIVOT and roundtripping
  • documentation: WIP docs for PIVOT/UNPIVOT documentation#143
  • syntax highlighting: todo
  • add sqllogictests

Desirable/future work

  • Extended PIVOT
    • support aliasing for FOR title i.e FOR abc IN ('a', 'b', 'c') as def
      • No idea for a use case here?
    • support aliasing columns inside IN expr i.e FOR foo IN ('bah' as baz)
    • support dynamic IN list i.e FOR foo IN (select some_string FROM table)
      • requires two-step compilation, to eagerly execute the subquery and build the PIVOT QueryModel with the correct column count/metadata
    • support an ELSE expression, to capture all rows not included in the FOR filter:
      • i.e FOR foo IN (bah) ELSE 'baz'
      • This is to make it easy to compare a subset of a symbol against the others. For example, how many BTC-USD trades occurred compared to all other trades.
      • the condition for 'baz' would be FOR foo NOT IN (bah)
  • Extended UNPIVOT now in feat(sql): support UNPIVOT keyword for rotating columns to rows #5732
    • Support multiple destination columns for UNPIVOT
    • Improve composability of generated column - built in ORDER BY

@nwoolmer nwoolmer added the SQL Issues or changes relating to SQL execution label Jan 13, 2025
@nwoolmer nwoolmer changed the title feat(sql): support PIVOT keyword for pivoting rows into columns feat(sql): support PIVOT and UNPIVOT keywords for rotating tables Jan 13, 2025
@nwoolmer nwoolmer marked this pull request as ready for review April 7, 2025 17:03
@bluestreak01
Copy link
Copy Markdown
Member

bluestreak01 commented May 2, 2025

this doesn't work

select RegionID, count() from hits
where regionid in (14430, 16260);

hits
PIVOT (
  count(),
  for regionid in (14430, 16260)
)

image

I think the issue here is that i put , after the count() - the error message leaves a lot to be desired :)

@bluestreak01
Copy link
Copy Markdown
Member

columns are not aliased correctly, simple SQL produces conflicts:

image

@bluestreak01
Copy link
Copy Markdown
Member

Alias without "as" does not work and produces erroneous error:

image

@bluestreak01
Copy link
Copy Markdown
Member

what is the purpose of "case" statements in the projections?

image

@bluestreak01
Copy link
Copy Markdown
Member

50% of the code in the new factory is untested

@bluestreak01
Copy link
Copy Markdown
Member

non-existent symbol produces NULL in double fields but not consistently in other types, for example 0 in long:

image

@bluestreak01
Copy link
Copy Markdown
Member

while it is cool and neat syntax modelled on Oracle/Snowflake syntax, it doesn't support aliases in "for" clause:

image

@bluestreak01
Copy link
Copy Markdown
Member

arithmetic with aggregates is not supported:

image

@kafka1991 kafka1991 marked this pull request as ready for review January 5, 2026 03:49
@kafka1991
Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@glasstiger
Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 741 / 815 (90.92%)

file detail

path covered line new line coverage
🔵 io/questdb/griffin/model/PivotForColumn.java 30 40 75.00%
🔵 io/questdb/std/CharSequenceHashSet.java 4 5 80.00%
🔵 io/questdb/std/ObjList.java 5 6 83.33%
🔵 io/questdb/griffin/SqlOptimiser.java 428 482 88.80%
🔵 io/questdb/griffin/engine/functions/conditional/CaseCommon.java 8 9 88.89%
🔵 io/questdb/griffin/SqlParser.java 164 171 95.91%
🔵 io/questdb/cutlass/http/processors/JsonQueryProcessor.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/bool/InStrFunctionFactory.java 4 4 100.00%
🔵 io/questdb/griffin/engine/QueryProgress.java 4 4 100.00%
🔵 io/questdb/cairo/DefaultCairoConfiguration.java 2 2 100.00%
🔵 io/questdb/PropertyKey.java 2 2 100.00%
🔵 io/questdb/griffin/SqlCompilerImpl.java 2 2 100.00%
🔵 io/questdb/cairo/CairoConfigurationWrapper.java 3 3 100.00%
🔵 io/questdb/griffin/SqlKeywords.java 17 17 100.00%
🔵 io/questdb/PropServerConfiguration.java 4 4 100.00%
🔵 io/questdb/griffin/engine/functions/conditional/SwitchFunctionFactory.java 4 4 100.00%
🔵 io/questdb/cutlass/http/processors/ExportQueryProcessor.java 4 4 100.00%
🔵 io/questdb/cutlass/pgwire/PGPipelineEntry.java 13 13 100.00%
🔵 io/questdb/griffin/ExpressionParser.java 4 4 100.00%
🔵 io/questdb/griffin/model/QueryModel.java 30 30 100.00%
🔵 io/questdb/griffin/engine/functions/IPv4Function.java 2 2 100.00%
🔵 io/questdb/cutlass/http/processors/ExportQueryProcessorState.java 1 1 100.00%
🔵 io/questdb/griffin/CompiledQueryImpl.java 2 2 100.00%
🔵 io/questdb/griffin/engine/functions/DoubleFunction.java 1 1 100.00%
🔵 io/questdb/cairo/CairoConfiguration.java 1 1 100.00%

@bluestreak01
Copy link
Copy Markdown
Member

this sql should error out

(trades limit 100000)0 
pivot (
  first(price), last(price)
  for symbol in ('sym1', 'sym2')
);

@kafka1991
Copy link
Copy Markdown
Collaborator

kafka1991 commented Jan 6, 2026

this sql should error out

(trades limit 100000)0 
pivot (
  first(price), last(price)
  for symbol in ('sym1', 'sym2')
);

It is actually meaningful here. 0 is used as the alias of the previous subquery. You can query it like this:

(trades limit 100000)0 
pivot (
  first("0.price"), last(price)
  for "0.sym" in ('sym1', 'sym2')
);

It’s worth noting that DuckDB also supports similar syntax.

@bluestreak01 bluestreak01 merged commit 35d0282 into master Jan 6, 2026
43 checks passed
@bluestreak01 bluestreak01 deleted the feat-pivot branch January 6, 2026 16:51
@tris0laris tris0laris moved this from Imminent Release to Shipped in QuestDB Public Roadmap (Legacy) Jan 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Core Related to storage, data type, etc. SQL Issues or changes relating to SQL execution

Projects

Development

Successfully merging this pull request may close these issues.

Add PIVOT function

5 participants