Tracing a DuckDB Query
Today, we will be tracing a DuckDB query plan alongside its corresponding c++ code.
DuckDB’s repo is roughly designed into the below sections:
- parser: parse sql into a syntax tree
- planner: bind names and optimize the query plan
- execution
- physical plan: turn the optimized plan into operations
- operator: operations performed
- order
- storage: tables and columns
- parallel: pipelines and scheduling
Below are the query plan operations:
- seq_scan: reading the table
- projection: choose or calculate columns
- hash_group_by: build grouped totals
- filter: keep rows matching a condition
- top_n: keep highest ranked rows
I have created a test DuckDB file in a local repo.
Here are the results of the test query:
SELECT
category,
count(*) AS orders,
sum(amount) AS revenue
FROM orders
WHERE ordered_at >= DATE '2024-10-01'
GROUP BY category
HAVING sum(amount) > 100000
ORDER BY revenue DESC
LIMIT 5;
| category | orders | revenue |
| toys | 31,418 | 4,358,717.50 |
| sports | 31,418 | 4,319,445.00 |
| office | 31,418 | 4,280,022.50 |
| home | 31,418 | 4,240,750.00 |
| garden | 31,418 | 4,201,827.50 |
DuckDB turns SQL into a series of internal operations, which we can obtain via the EXPLAIN command before a query.
TOP_N: Top 5, sum(amount) DESC 5 rows out
PROJECTION: category, orders, revenue 8 rows
FILTER: sum(amount) > 100000.00 8 rows
PROJECTION: __internal_decompress_string(#0), #1, #2
HASH_GROUP_BY: group #0; sum_no_overflow, count_star() 8 rows
PROJECTION: category, amount 251,344 rows
PROJECTION: __internal_compress_string_uhugeint(#0), #1
SEQ_SCAN orders: ordered_at >= 2024-10-01 251,344 rows
Here is what happened in the query:
- 1,000,000 orders in the dataset
- check if date is on or after 2024-10-01
- 251,344 orders remain
- group by category, calculate count and sum
- 8 categories remain
- condition: having sum(amount) > 100,000 - all 8 pass
- 8 categories remain
- keep top 5 by revenue expression
- return the final 5 result rows
- keep top 5 by revenue expression
- 8 categories remain
- condition: having sum(amount) > 100,000 - all 8 pass
- 8 categories remain
- group by category, calculate count and sum
- 251,344 orders remain
- check if date is on or after 2024-10-01
Now we will dive into how the code executes these operations.
DuckDB processes data in DataChunk objects, which is a small in-memory batch of rows.
DuckDB gives the date condition to the scan before it starts reading.
The scan then produces the next batch of rows.
The code calls the “table function” which reads the orders table.
// src/execution/operator/scan/physical_table_scan.cpp
auto filters = table_filters ? *table_filters : GetTableFilters(op);
TableFunctionInitInput input(op.bind_data.get(), op.column_ids, op.projection_ids, filters,
op.extra_info.sample_options, &op);
global_state = op.function.init_global(context, input);
// PhysicalTableScan::GetData
TableFunctionInput data(bind_data.get(), l_state.local_state.get(), g_state.global_state.get());
function.function(context.client, data, chunk);
return chunk.size() == 0 ? SourceResultType::FINISHED : SourceResultType::HAVE_MORE_OUTPUT;
Next, the columns and prepared via the projection operation.
This is achieved by evaluating the list of expressions for each batch:
// src/execution/operator/projection/physical_projection.cpp
OperatorResultType PhysicalProjection::Execute(ExecutionContext &context, DataChunk &input, DataChunk &chunk,
GlobalOperatorState &gstate, OperatorState &state_p) const {
auto &state = state_p.Cast<ProjectionState>();
state.executor.Execute(input, chunk);
return OperatorResultType::NEED_MORE_INPUT;
}
In the group by operation, DuckDB finds the entry for each category in a hash table and updates its running count and revenue total:
// src/execution/operator/aggregate/physical_hash_aggregate.cpp
aggregate_input_chunk.SetCardinality(chunk.size());
aggregate_input_chunk.Verify();
for (idx_t i = 0; i < groupings.size(); i++) {
auto &grouping_global_state = global_state.grouping_states[i];
auto &grouping_local_state = local_state.grouping_states[i];
OperatorSinkInput sink_input {*grouping_global_state.table_state, *grouping_local_state.table_state,
interrupt_state};
auto &grouping = groupings[i];
grouping.table_data.Sink(context, chunk, sink_input, aggregate_input_chunk, non_distinct_filter);
}
After it has seen all matching orders, DuckDB finishes the hash table and reads the category totals back out, reducing the problem set from 251,344 to 8.
// PhysicalHashAggregate::FinalizeInternal
grouping.table_data.Finalize(context, *grouping_gstate.table_state);
// PhysicalHashAggregate::GetData
auto res = radix_table.GetData(context, chunk, *grouping_gstate.table_state, source_input);
if (chunk.size() != 0) {
return SourceResultType::HAVE_MORE_OUTPUT;
}
Above, you see DuckDB’s implementation of radix, which is essentially a method of building local aggregate hash tables, dividing their entries into buckets using bits from each entry’s hash, and then processing those buckets independently.
The filter operation in the having sum(amount) > 100000 is a straight forward check against the data chunk:
idx_t result_count = state.executor.SelectExpression(input, state.sel);
if (result_count == input.size()) {
chunk.Reference(input);
} else {
chunk.Slice(input, state.sel, result_count);
}
DuckDB orders and limits by sending the incoming batch to a local heap, which is a small ranked list that makes it cheap to retain current rows.
In this case, it holds 5 entries.
// src/execution/operator/order/physical_top_n.cpp
sink.heap.Sink(chunk, &gstate.boundary_value);
sink.heap.Reduce();
// TopNHeap::Sink
if (heap_size <= SMALL_HEAP_THRESHOLD) {
AddSmallHeap(input, sort_keys_vec);
}
// TopNHeap::AddSmallHeap
if (!EntryShouldBeAdded(sort_key)) {
continue;
}
AddEntryToHeap(entry);
I hope this post helped shed some light into how sql plans get executed under the hood.
I am not an expert c++ developer by any means, but DuckDB’s code base is pretty readable and organized, which made tracing this a bit easier than I expected!