Skip to content

Push down type casts to file readers - #22788

Merged
Mytherin merged 13 commits into
duckdb:mainfrom
myrrc:myrrc/cast-type-pushdown
Jun 26, 2026
Merged

Mytherin merged 13 commits into
duckdb:mainfrom
myrrc:myrrc/cast-type-pushdown

Conversation

@myrrc

@myrrc myrrc commented May 20, 2026 •

Copy link
Copy Markdown
Contributor

As reported in #22738, type casts are usually not propagated to file readers. For Vortex, this means list aggregation routines like list_sum(GT) (executed as list_sum(CAST(GT AS UHUGEINT[]))) do upcasting per-Vector instead of doing it once in Vortex.

This PR solves this by transforming CAST(col, T) -> GET nodes into GET (col as T) if there are no conflicting casts.
This PR replaces type_pushdown function with projection_expression_pushdown which can choose to push down specific projection expressions. This only uses cast pushdown as for now.

This PR also adds MultiFileInfo projection expression (casts only) pushdown. support for Parquet.

Resolves: #22738

@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch 2 times, most recently from 7004997 to b18f5c8 Compare May 20, 2026 12:54
@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch 2 times, most recently from b95394b to 71abd21 Compare May 20, 2026 12:59
@myrrc

myrrc commented May 20, 2026 •

Copy link
Copy Markdown
Contributor Author

You can verify the PR this way:

~/duckdb duckdb -c "explain select list_sum(GT) from '/home/myrrc/vortex/vortex-bench/data/statpopgen/100000/parquet/gnomad.genomes.v3.1.2.hgdp_tgp.chr21.parquet'"

Before:

┌─────────────────────────────┐
│┌───────────────────────────┐│
││       Physical Plan       ││
│└───────────────────────────┘│
└─────────────────────────────┘
┌───────────────────────────┐
│         PROJECTION        │
│    ────────────────────   │
│    list_aggr(CAST(GT AS   │
│     HUGEINT[]), 'sum')    │
│                           │
│       ~100,000 rows       │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│        PARQUET_SCAN       │
│    ────────────────────   │
│         Function:         │
│        PARQUET_SCAN       │
│                           │
│      Projections: GT      │
│                           │
│       ~100,000 rows       │
└───────────────────────────┘

After:

┌─────────────────────────────┐
│┌───────────────────────────┐│
││       Physical Plan       ││
│└───────────────────────────┘│
└─────────────────────────────┘
┌───────────────────────────┐
│         PROJECTION        │
│    ────────────────────   │
│    list_aggr(#0, 'sum')   │
│                           │
│       ~100,000 rows       │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│        PARQUET_SCAN       │
│    ────────────────────   │
│         Function:         │
│        PARQUET_SCAN       │
│                           │
│      Projections: GT      │
│                           │
│       ~100,000 rows       │
└───────────────────────────┘

@Mytherin Mytherin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - extending the usage of type_pushdown sounds like a good idea to me - but I think this is perhaps better done as an optimizer pass rather than as part of the binder. It would be great if this also replaces the existing implementation in CastLogicalOperatorToTypes which does a more limited version of this.

As for the actual implementation - we need to find all casts that occur for a given column, not just all casts that occur in the operator above the GET. The current implementation leaves any casts that occur prior to the current operator alone which causes issues, e.g. this now crashes because we don't detect the conflict in casts as we only find the cast in the LogicalFilter, missing the cast in LogicalProjection.

COPY (SELECT '2020-01-01 02:20:30' ts) TO 'file.csv';
SELECT ts::TIMESTAMP FROM 'file.csv' WHERE ts::DATE = '2020-01-01';

It's also not sufficient just to find conflicts in casts, if the column is at any point used in a place where it is not directly cast to a given type there is a conflict. For example:

COPY (SELECT '2020-01-01 02:20:30' ts) TO 'file.csv';
SELECT ts, ts::DATE FROM 'file.csv';

In general these kinds of changes are complex and it is easy to introduce bugs so it would be great if you could add more tests as well.

As a side-note, I think the MultiFile type pushdown for Parquet is not having any effect as these queries work with Parquet somehow. Likely the type pushdown is just being silently ignored by the Parquet reader and the original type is emitted.

@myrrc

myrrc commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, I'll definitely add more tests today.
As for your specific example, I think it depends whether the file reader supports cast expression pushdowns, i.e. if Parquet did some type casting, this would be fine:

┌───────────────────────────┐
│         PROJECTION        │
│    ────────────────────   │
│   CAST(ts AS TIMESTAMP)   │
│           ~1 row          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│        PARQUET_SCAN       │
│    ────────────────────   │
│         Function:         │
│        PARQUET_SCAN       │
│                           │
│      Projections: ts      │
│                           │
│          Filters:         │
│ (CAST(ts AS DATE) = '2020 │
│       -01-01'::DATE)      │
│           ~1 row          │
└───────────────────────────┘

In the plan filter and projection are decoupled, so reader function can return casted timestamp.

@Mytherin

Mytherin commented May 21, 2026 •

Copy link
Copy Markdown
Member

I think you might be conflating filter pushdown and type pushdown. What type_pushdown specifies is the result type of the given column, i.e. in that example we tell the scan the ts column is of type DATE. This was originally implemented for the CSV reader since types are not known there and we do auto-detection, so this basically assists it by (1) allowing it to skip auto-detection, and (2) prevent it from incorrectly detecting the wrong types. No other readers implement this at the moment.

In the given example if we are telling the scan ts is of type DATE then the query plan is wrong, because later on ts is used as a TIMESTAMP. Imagine if ts was a string inside the source file - if we cast it to DATE in the scan first and emit it as a DATE we lose information (namely the hour/minute/second component of the value) and the query produces wrong results.

@myrrc

myrrc commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

In the given example if we are telling the scan ts is of type DATE then the query plan is wrong

No, I'm saying if a file reader supports expression pushdown (cast pushdown specifically), we can request "ts AS TIMESTAMP" as projection, because "ts AS DATE" will be pushed as a filter, and there are no casted or direct usages of ts.

And I also believe the reason Parquet/Arrow readers doesn't support type pushdown is because of UHUGEINT/IHUGEINT. This is the case for Vortex as well. So if you're open to breaking changes for 2.0, I'd suggest type_pushdown should either behave how pushdown_complex_filter does (get a list of output types, push down those supported, let duckdb handle the rest), or how expression pushdown does (separate function can_pushdown_type).
With this API i.e. Vortex would push everything but UHUGEINT/IHUGEINT.

@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch 2 times, most recently from 584f34f to 19c9305 Compare May 27, 2026 14:56
@myrrc

myrrc commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

I've pushed an updated version with tests.
This still won't push down all types, i.e. on SELECT list_sum(cast(list as USMALLINT[])) only the inner cast to small int will be propagated, but to fix this you need to make the traversal and replacement run multiple times, and I'm not sure it's the best for performance.

@myrrc
myrrc requested a review from Mytherin May 27, 2026 14:56
@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch 3 times, most recently from 4e87713 to 46fe386 Compare May 28, 2026 10:34
@duckdb-draftbot
duckdb-draftbot marked this pull request as draft June 1, 2026 11:54
@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch from 46fe386 to e13db4f Compare June 1, 2026 12:18
@myrrc
myrrc marked this pull request as ready for review June 1, 2026 12:18

@Mytherin Mytherin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Looks good - I've left some comments.

Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/optimizer.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Outdated
TypePushdown::TypePushdown(ClientContext &context) : context(context) {
}

using GetCastMap = unordered_map<column_t, LogicalType>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are all of these top-level using clauses necessary? Looking at the code I think almost all of them can be replaced with auto & instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a matter of taste, I prefer AAAA (always almost avoid auto), but I can remove them, not a big deal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure, it's a nit, but in general we prefer to have a consistent code-style as it simplifies reading / understanding code in the code base without having different dialects / styles in different modules.

Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Outdated
if (expr.GetExpressionClass() != ExpressionClass::BOUND_CAST) {
return collect_children();
}
const auto &bound_cast = expr.Cast<BoundCastExpression>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check try_cast here as well - can you do this + add a test with try_cast and a mix of cast + try_cast on the same column?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does try_cast need any special handling? If a cast fails, it's on the extension to propagate this correctly since cast pushdown is already an extension point. +1 for tests

@Mytherin Mytherin Jun 2, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension currently does not know a TRY_CAST was requested, so this will likely turn from working and returning NULL into an error:

COPY (SELECT 'hello world' varchar_col) TO 'varchar.parquet';
SELECT TRY_CAST(varchar_col AS DATE) FROM read_parquet('varchar.parquet');

Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
Comment thread src/optimizer/type_pushdown.cpp Resolved
@myrrc

myrrc commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@Mytherin I've pushed an updated version. The question about type casts which are not supported in extension i.e. HUGEINT for Vortex still remains - we won't be able to use pushdown if there's no way to decline the cast.

Maybe this PR should also use expression_pushdown TableFunction callback to check whether this is supported?

@myrrc
myrrc requested a review from Mytherin June 2, 2026 11:44
@Mytherin

Mytherin commented Jun 2, 2026

Copy link
Copy Markdown
Member

pushdown_expression is about table filters so I think that's not the right abstraction here.

We could redesign the type_pushdown to maybe pushing a generic expression instead? e.g.:

bool pushdown_projection(ClientContext &context, const LogicalGet &get, const Expression &expr, idx_t proj_index);

That would be more flexible going forward - then we just wouldn't accept anything other than casts for now.

@Mytherin

Mytherin commented Jun 2, 2026

Copy link
Copy Markdown
Member

If we redesign the function we should also move towards using a struct for input parameters (similar to TableFunctionGetPartitionInput and such).

@myrrc

myrrc commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

bool pushdown_projection(ClientContext &context, const LogicalGet &get, const Expression &expr, idx_t proj_index);

What if multiple projections for a column were requested e.g. SELECT len(str), substr(str, 2)?

I've thought about this for a while in vortex-data/vortex#8190. If we go this route, Duckdb can request multiple same columns in projections i.e. for the query above
column_ids=[0], projection_ids=[0, 0], and pushdown_projection may be called twice:

pushdown_projection(.., Expression{len}, 0) -- first col in SELECT
pushdown_projection(.., Expression{len}, 1) -- second col in SELECT

However, if we want to further expand this to aggregate functions, we should also be notified of the output types. This shouldn't be hard, though

@Mytherin

Mytherin commented Jun 2, 2026 •

Copy link
Copy Markdown
Member

I would say we can skip projection pushdown in that case similar to how we currently skip cast pushdown if the same column is scanned with different types - but it's an interesting angle to consider perhaps going forward.

@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch from a46849b to b2ad139 Compare June 26, 2026 09:21
@duckdb-draftbot
duckdb-draftbot marked this pull request as draft June 26, 2026 09:22
@myrrc

myrrc commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Rebased, thanks

@myrrc
myrrc marked this pull request as ready for review June 26, 2026 09:22
@duckdb-draftbot
duckdb-draftbot marked this pull request as draft June 26, 2026 09:23
@myrrc
myrrc force-pushed the myrrc/cast-type-pushdown branch from a397c20 to 366c814 Compare June 26, 2026 09:24
@myrrc
myrrc marked this pull request as ready for review June 26, 2026 09:25
@Mytherin
Mytherin added this pull request to the merge queue Jun 26, 2026
@Mytherin

Copy link
Copy Markdown
Member

Thanks!

@myrrc myrrc linked an issue Jun 26, 2026 that may be closed by this pull request
5 of 8 tasks
Merged via the queue into duckdb:main with commit 4f6deb4 Jun 26, 2026
76 checks passed
@myrrc
myrrc deleted the myrrc/cast-type-pushdown branch June 26, 2026 15:41
c-herrewijn pushed a commit to c-herrewijn/duckdb that referenced this pull request Jul 1, 2026
This a fix for duckdb#22788.
Previously, we allowed to push CAST and TRY_CAST at once if type for
column was the same.

It doesn't work for readers like Vortex which have cast() but not
try_cast(). After this PR, CAST+TRY_CAST is registered as a conflict
myrrc added a commit to vortex-data/vortex that referenced this pull request Jul 8, 2026
- Backport of duckdb/duckdb#22788 to push down
casts, optimizer pass. Extract both passes common parts to optimizer.cpp
- Map 8- and 16-bit types in sqllogictest runner to INTEGER.
- Add tracing subscriber logging to sqllogictest runner to support
RUST_LOG=debug.
- Support of CAST (not TRY_CAST) over primitives pushdown on Rust side.
U128/I128 are not pushed.

We are overly restrictive here because we don't want clients to face
issues like `No CastReduce to cast constant array from
vortex.date[days](i32) to vortex.timestamp[µs](i64?)`. We can lift the
restriction later.

Signed-off-by: Mikhail Kot <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic: Duckdb projection expression pushdown Type pushdown isn't called for casts.

2 participants