Skip to content

feat(bigquery): use native UNPIVOT for pivot_longer - #12091

Open
dlstadther wants to merge 7 commits into
ibis-project:mainfrom
dlstadther:bq-unpivot
Open

dlstadther wants to merge 7 commits into
ibis-project:mainfrom
dlstadther:bq-unpivot

Conversation

@dlstadther

Copy link
Copy Markdown
Contributor

Description of changes

Table.pivot_longer currently compiles to a generic struct-pack / array / unnest expression on every backend, including BigQuery, even though BigQuery has a native UNPIVOT operator for exactly this shape of query. This PR adds a BigQuery-specific compilation path that uses UNPIVOT when possible, matching the existing pattern of backend-specific rewrites via LOWERED_OPS.

  • ops.PivotLonger is now a real relational op (instead of being eagerly expanded into struct/array/unnest at expression-build time), so a backend can special-case it during compilation. to_generic() on the op still produces the original struct/array/unnest tree and remains the implementation for every other backend.
  • On BigQuery, a pivot_longer call with a single names_to column compiles to a native UNPIVOT. A call with more than one names_to column (e.g. from a multi-group names_pattern) still falls back to the generic implementation, since UNPIVOT (in both its single-column and multi-column forms) only ever produces a single name column — it can't express multiple names_to columns.
  • Two BigQuery-specific correctness issues had to be handled to make this a safe drop-in replacement:
    • UNPIVOT's IN (...) list requires every column to share the exact same type (no implicit widening), so pivot columns of different-but-unifiable types (e.g. int64 + float64) are now explicitly cast to the pivoted column's unified type before entering the IN list.
    • UNPIVOT's physical output column order is always [...passthrough, value_col, name_col], which does not match PivotLonger's declared schema order of [...passthrough, name_col, value_col]. Since results are mapped to schema positionally, this silently swapped the name/value columns' data. The generated SQL now selects columns explicitly in schema order rather than relying on SELECT *.
  • Added values_drop_na: bool = False to pivot_longer. When True, rows where the resulting values_to column is NULL are dropped (result.drop_null(values_to) in the generic implementation). This is implemented once in to_generic(), so every backend gets it for free. On BigQuery it maps to UNPIVOT's native EXCLUDE NULLS modifier instead. pivot_longer keeps NULLs by default (values_drop_na=False) on every backend, which is the opposite of UNPIVOT's own default (EXCLUDE NULLS) — the generated BigQuery SQL always states INCLUDE NULLS or EXCLUDE NULLS explicitly, so it never silently relies on BigQuery's default.

All of the above (native UNPIVOT compilation, the type-cast fix, the column-order fix, and values_drop_na) were also verified against a live BigQuery project, comparing results row-for-row against pandas.melt(), in addition to the unit tests included here.

This is a drop-in replacement: output is identical to the previous struct/array/unnest implementation for BigQuery users, and there is no behavior change for any other backend.


This addresses an active query performance issue I'm hitting in production use of Ibis against BigQuery. AI (Claude) was used to aid in scoping and implementing these changes, including the live BigQuery testing referenced above.

dlstadther and others added 4 commits August 27, 2026 11:16
BigQuery's UNPIVOT is significantly cheaper than the generic
struct-packing/array/unnest lowering pivot_longer uses by default.
Introduce ops.PivotLonger as a proper relational op (schema and the
generic lowering both derive from a single to_generic() implementation,
so every other backend is unaffected) and give BigQuery a conditional
LOWERED_OPS rewrite that renders UNPIVOT INCLUDE NULLS when there's a
single names_to column, falling back to the generic path otherwise
(UNPIVOT only ever produces one name column).

Two correctness gaps surfaced via a live BigQuery smoke test and got
fixed: BigQuery's UNPIVOT IN-list requires exact type equality across
columns (unlike ibis's own array/struct unification), so each pivoted
column is cast to the resolved value type first; and UNPIVOT's physical
column order (value_col, name_col) doesn't match ibis's declared schema
order (name_col, value_col), which silently mislabeled results, so the
outer select now lists columns explicitly in schema order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWzsGWfxosFyz9abPziykS
…CLUDE NULLS

pivot_longer had no way to drop rows where the pivoted value is NULL
short of a separate .drop_null() call after the fact. Add values_drop_na
(named after tidyr's pivot_longer argument of the same purpose), applied
in PivotLonger.to_generic() so every backend gets it via the existing
generic lowering, and mapped to BigQuery's native UNPIVOT EXCLUDE NULLS
instead of a post-filter when the native path is taken. Default is False,
matching the existing always-keep-NULL-rows behavior.

Verified against real BigQuery: values_drop_na=True drops the NULL row
via EXCLUDE NULLS, values_drop_na=False (default) still uses INCLUDE
NULLS unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWzsGWfxosFyz9abPziykS
…lt for pivot_longer

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWzsGWfxosFyz9abPziykS
… INCLUDE NULLS

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWzsGWfxosFyz9abPziykS
@github-actions github-actions Bot added tests Issues or PRs related to tests bigquery The BigQuery backend sql Backends that generate SQL labels Aug 27, 2026
Polars uses its own singledispatch `translate` compiler, not
SQLGlotCompiler's `LOWERED_OPS` lowering, so the new `PivotLonger`
relational op reached it untranslated and raised
`OperationNotDefinedError`. Register a translation that falls back to
`to_generic()`, same as every other non-BigQuery backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWzsGWfxosFyz9abPziykS
@github-actions github-actions Bot added the polars The polars backend label Aug 28, 2026
@deepyaman

deepyaman commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

This looks reasonable at a high level. Can you do me a favor and try and check that the added code is idiomatic and aligned in style with the rest of the repo? Some of the AI-coding things stick out even at a glance, like the massive comments and notes (which you won't see throughout the rest of the repo); haven't looked deeply, but this will help reduce some churn.

Other question that will come up with is, which over backends could benefit from switching to a native UNPIVOT? I imagine it's not just BigQuery; haven't looked, and not asking you to change other backends in this PR (if anything, maybe this PR scoped to BigQuery will be easier to review), but it will be helpful to have an answer for which backends adding the native op could potentially benefit.

I'll try to get to reviewing this properly once these changes are in, but preemptively forgive the fact that there will probably be some delay.

@dlstadther

Copy link
Copy Markdown
Contributor Author

Thanks for your quick initial response @deepyaman . I'll go back through this proposal and ensure it aligns in style with rest of Ibis. I'll also trim unnecessary comments at the same time.

As for scope of changes and other areas that could benefit from UNPIVOT, I suspect there are other candidates, but I wish to keep this PR scoped to what is minimally required to support the backend override + BigQuery + address failing tests (which is why there are adjustments to polars here).

Comments and docstrings around the PivotLonger UNPIVOT lowering were
much longer and more note-like than the rest of the codebase's style.
Trim them to match the terse, why-only comment convention used
elsewhere (e.g. visit_TableUnnest, translate.register functions).

Addresses review feedback from @deepyaman on PR ibis-project#12091.
…h repo conventions

- Rename lower_pivot_longer_bigquery -> pivot_longer_to_unpivot. No other
  backend-specific @replace rewrite in this codebase uses the "lower_"
  prefix (reserved for shared rewrites.py/base.py factories) or a
  redundant backend-name suffix; local ones are named for what they do
  (offset_to_filter, rewrite_rows_range_order_by_window, etc).
- Replace `import ibis` + `ibis.struct(...)`/`ibis.array(...)` in
  PivotLonger.to_generic() with `from ibis.expr.types import array, struct`.
  A top-level import isn't possible here (confirmed: ibis.expr.api pulls in
  ibis.expr.types -> ibis.expr.builders -> ops before ops finishes loading,
  a circular import); the deferred import stays, narrowed to the specific
  names' actual home, matching the same pattern already used in
  ops/core.py.

Continues addressing review feedback from @deepyaman on PR ibis-project#12091.
@dlstadther

dlstadther commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Went back through and trimmed the AI-generated bloat:

  • Comments/docstrings: cut the multi-paragraph "notes" style comments in bigquery/__init__.py, expr/operations/relations.py, expr/types/relations.py, and backends/polars/compiler.py down to match the terse, why-only comment convention used elsewhere in the codebase.
  • Naming: renamed lower_pivot_longer_bigquery -> pivot_longer_to_unpivot. No other backend-specific @replace rewrite in the codebase uses a lower_ prefix (reserved for shared rewrites.py/base.py factories) or a redundant backend-name suffix — local ones are named for what they do (offset_to_filter, rewrite_rows_range_order_by_window, etc).
  • Imports: PivotLonger.to_generic() did import ibis then ibis.struct(...)/ibis.array(...). Narrowed to from ibis.expr.types import array, struct (the functions' actual home), matching the existing precedent in ops/core.py for this same circular-import constraint (confirmed a true top-level import isn't possible here — ibis.expr.api pulls in ibis.expr.types -> ibis.expr.builders -> ops before ops finishes loading).

Left PivotLonger.schema as self.to_generic().schema() rather than computing dtypes directly — happy to discuss further if you'd rather see it changed.

Still scoped to BigQuery only per the earlier discussion; the "which other backends could benefit" question is open for whenever you get to a full review.


I have not checked on usefulness for other backends yet. Though, i still would NOT advocate to expand the scope of this PR beyond BQ + necessary for tests/compatibility.

@dlstadther

Copy link
Copy Markdown
Contributor Author

Hi @deepyaman , checking in to see if you (or any other maintainer) have opportunity to review

@deepyaman

deepyaman commented Sep 17, 2026 via email

Copy link
Copy Markdown
Collaborator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bigquery The BigQuery backend polars The polars backend sql Backends that generate SQL tests Issues or PRs related to tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants