The privacy extension optimizer hook transforms standard SQL aggregate plans into privacy-preserving equivalents. It operates as a pre-optimizer, running before DuckDB's built-in optimizers (join ordering, filter pushdown, column lifetime, compressed materialization), so the transformed plan benefits from all standard optimizations automatically.
The active mode (privacy_mode) determines which compiler runs. This document covers
the pac compiler; the DP modes are documented under ../dp/.
pac(default): Proceeds in two phases matching Algorithm 1 in the PAC paper — top-down PU join insertion + hash derivation, then bottom-up aggregate replacement and categorical rewriting.dp_standard: Global-sensitivity DP (Google DP) — per-PU contribution clipping, Laplace noise; private partition selection for grouped queries.dp_elastic: FLEX elastic sensitivity — extracts the FK join chain, computes the smoothed join-frequency envelope, clips SUM inputs per-row, injects Laplace noise.dp_sass: Sample-and-aggregate (this paper's SIMD-SAA) — per-lane aggregates, median (smooth-sensitivity) or GUPT mean release.
See ../dp/dp_mechanisms.md for the DP modes.
When a query is issued, the compatibility checker traverses the logical plan and classifies it into one of three categories:
Queries that do not reference a PU or a PAC-linked table. These execute unmodified.
Queries that reference privacy-relevant tables but cannot be safely privatized. Rejected when:
- A protected column is returned directly without aggregation (e.g.,
SELECT name FROM customer) - Protected columns appear in
GROUP BYkeys (would leak individual values) - The query contains window functions (
OVER) - The query contains recursive CTEs (
WITH RECURSIVE) - The query contains disallowed joins:
EXCEPT,INTERSECT(butUNION/CROSS_PRODUCTare allowed) - The query has no allowed aggregation (
SUM,COUNT,AVG,MIN,MAX) - Multiple protected tables are joined on columns that are not exact PAC LINKs
- The FK path from scanned tables to PU is cyclic
All other queries that reference a PU (directly or indirectly via FK path) and contain at least one allowed aggregation. These are compiled by the bitslice compiler.
The bitslice compiler derives a hash expression for each aggregate and replaces it with a PAC function. This is a single unified approach with a preparatory step: if the PU table is not already part of the query, the compiler first adds FK joins to make it reachable.
When the query does not scan the PU table directly (e.g., SELECT SUM(l_quantity) FROM lineitem):
- Follow FK path from scanned tables to PU (e.g.,
lineitem -> orders -> customer) - Identify missing tables in the path that need to be joined
- Join elimination: if
priv_join_elimination=true, skip joining the PU table itself when only FK columns are needed (e.g., join onlyorders, notcustomer, becauseorders.o_custkeyis the FK) - For each instance of the connecting table:
- Create fresh
LogicalGetnodes for missing tables - Build join chain with appropriate FK conditions
- Replace the connecting table's node with the join chain
- Create fresh
Once the PU (or its FK proxy) is reachable from every aggregate:
- Find all aggregates that have the PU table (or FK-linked table) in their subtree
- For each PU table, compute a hash from the PU's key columns or FK columns:
- Single PRIVACY_KEY:
priv_hash(hash(key_col)) - Composite PRIVACY_KEY:
priv_hash(hash(key1) XOR hash(key2) XOR ...)— XOR combines multiple column hashes into one, then priv_hash repairs it to 32 bits - FK proxy:
priv_hash(hash(fk_col))(e.g.,priv_hash(hash(o_custkey)))
- Single PRIVACY_KEY:
- Insert a projection above the hash source scan that computes the hash as an extra column
- Propagate the hash column through all intermediate operators (projections, joins, filters) between the hash source and the aggregate
- Replace each standard aggregate with its PAC equivalent:
SUM(x)becomesas_noised_sum(hash, x),COUNT(*)becomesas_noised_count(hash), etc. - For multiple PUs, AND all hashes together:
hash1 AND hash2— a tuple is in sub-sample j only if ALL its PUs have bit j set
PAC aggregate naming: base functions (
as_count,as_sum, ...) returnLIST<T>— 64 per-sample counters.as_noised(list<T>):Tapplies noise and returns a scalar. Theas_noised_<aggr>()variants (e.g.,as_noised_count) are fused shortcuts:as_noised_count(hash)=as_noised(as_count(hash)). Similarly,priv_selectandpriv_filterconsume counter lists for categorical queries.
-- Original
SELECT COUNT(*) FROM customer WHERE c_mktsegment = 'BUILDING'
-- Rewritten (conceptual)
SELECT as_noised_count(priv_hash(hash(c_custkey)))
FROM customer WHERE c_mktsegment = 'BUILDING'-- Original
SELECT l_returnflag, SUM(l_quantity) FROM lineitem
WHERE l_shipdate <= '1998-09-02' GROUP BY l_returnflag
-- Step 1: join orders to reach PU FK column
-- Step 2: hash o_custkey, replace SUM with as_noised_sum
SELECT l_returnflag, as_noised_sum(priv_hash(hash(o_custkey)), l_quantity)
FROM lineitem JOIN orders ON l_orderkey = o_orderkey
WHERE l_shipdate <= '1998-09-02' GROUP BY l_returnflagUncorrelated subqueries (e.g., WHERE x > (SELECT AVG(y) FROM t)) are handled by DuckDB as a SINGLE join (returns exactly one row). The PAC compiler:
- Finds all aggregates in the plan (including those inside the subquery)
- Filters to target aggregates — only those that have PU/FK-linked tables in their direct subtree (not through nested aggregates)
- Inserts hash projections and transforms each aggregate independently
- The outer query's aggregate and the subquery's aggregate each get their own hash
The subquery's aggregate result feeds into the outer query's filter/comparison. When the subquery contains a PAC aggregate and the outer query doesn't aggregate (categorical query), the categorical rewriter handles it (see below).
Correlated subqueries (e.g., TPC-H Q17: WHERE l1.quantity < (SELECT AVG(l2.quantity) FROM lineitem l2 WHERE l2.partkey = l1.partkey)) are compiled by DuckDB into DELIM_JOIN operators. The PAC compiler handles these specially:
- Detect when the same connecting table appears in both outer and inner query (e.g.,
lineitemappears twice) - Find all instances of the connecting table in the plan tree
- Add independent join chains to each instance (each gets its own
ordersjoin) - DELIM_JOIN propagation: DuckDB compiles correlated subqueries into DELIM_JOIN operators, which have a "duplicate eliminated columns" mechanism for passing values from the outer query into the inner subquery. When the hash source is in the outer query but the target aggregate is in the subquery branch, the compiler adds the hash column to the DELIM_JOIN's duplicate-eliminated columns and corresponding DELIM_GET nodes, so the inner aggregate can access it.
- Accessibility checks: verify FK table columns aren't blocked by SEMI/ANTI/MARK joins (which don't pass right-side columns). If blocked, find an accessible alternative or add a fresh join.
When the FK table is on the right side of a SEMI/ANTI join (e.g., WHERE EXISTS (SELECT ... FROM orders ...)), its columns are inaccessible to the outer aggregate. The compiler:
- Searches for an accessible FK table instance in the outer query
- If none found, looks for an alternative accessible table with an FK to the blocked table and adds a new join
The compiler supports materialized CTEs (WITH ... AS MATERIALIZED):
- Build CTE table map: maps each CTE's index to the set of base tables it transitively references (including through nested CTE refs). This is resolved once at the start of compilation.
- CTE-aware subtree checks: when checking if a table is reachable from an operator, the compiler follows CTE_REF nodes through the map to check if the referenced CTE transitively contains the target table.
- Hash projection above CTE_SCAN: when the PU table is accessed through a CTE, the compiler can't modify the CTE definition (it's shared across all scan sites). Instead, it inserts a hash projection above the CTE_SCAN node that reads the PU key columns from the CTE's output and computes the hash there.
- CTE definition propagation: the CTE definition must expose the PU key columns in its output for the hash to be computable on the scan side.
Non-materialized CTEs are inlined by DuckDB before the PAC optimizer runs, so they appear as regular subquery trees.
The compiler handles the pattern where an inner aggregate groups by PU key and an outer aggregate aggregates over those results (e.g., TPC-H Q13: inner counts orders per customer, outer counts how many customers have each order count):
- Detect PU-key grouping: when filtering which aggregates to transform, the compiler checks if an inner aggregate's GROUP BY keys contain the PU's PRIVACY_KEY columns or PRIVACY_LINK columns referencing a PU. This is done by inspecting the column bindings in the aggregate's group expressions and tracing them back to their source table scans.
- When detected, the inner aggregate is skipped (it's already partitioned by PU key, so no noise needed there — each group corresponds to a single PU)
- The outer aggregate is noised instead, using the inner aggregate's PU key group column as the hash input. The compiler locates the inner aggregate, finds the column binding for the PU key in its output, and uses that as the hash source for the outer aggregate.
- This means the hash for the outer aggregate comes from the inner aggregate's GROUP BY output rather than from a table scan — the inner aggregate preserves PU identity in its grouping columns.
-- Inner aggregate groups by c_custkey (PU key) - NOT noised
-- Outer aggregate counts over those groups - IS noised
SELECT c_count, COUNT(*) AS custdist FROM (
SELECT c_custkey, COUNT(o_orderkey) AS c_count
FROM customer LEFT JOIN orders ON ...
GROUP BY c_custkey -- groups by PU key
) GROUP BY c_countTop-K queries (ORDER BY agg LIMIT k) get special treatment via a dedicated post-optimizer rule:
Problem: In the default plan, PAC noise is applied at the aggregate level (below TopN). TopN then operates on noisy values, potentially selecting wrong groups.
Solution (pac_pushdown_topk=true):
- Convert PAC aggregates to
_countersvariants (return all 64 counter values asLIST<FLOAT>) - Insert a mean projection (
priv_mean(counters)) for ordering — gives the true aggregate mean - TopN selects top-k groups based on the true mean
- Insert a noised projection (
as_noised(counters)) above TopN — applies noise only to selected rows, cast back to original type
Two paths depending on plan structure:
- Path A (intermediate projections, e.g., string decompress): preserves intermediate projections, adds
priv_meanpassthrough columns - Path B (TopN directly above Aggregate): simpler insertion of MeanProj and NoisedProj
Superset expansion (pac_topk_expansion): select ceil(c * K) candidates with the inner TopN, then a final TopN limits to the original K after noising.
Aggregate DISTINCT (e.g., COUNT(DISTINCT col)) is rewritten:
COUNT(DISTINCT x)is handled via pre-aggregation: the compiler inserts aGROUP BY xwithbit_or(key_hash)before the standardas_noised_countaggregateSUM(DISTINCT x)similarly usesGROUP BY xwithbit_or(key_hash)beforeas_noised_sum
The compiler detects when an aggregate expression is marked as distinct and inserts the pre-aggregation step automatically.
Categorical queries arise when a (scalar or correlated) subquery produces a PAC aggregate result that is used outside of a direct aggregation context — typically in projection expressions or filter predicates. The categorical rewriter (Phase 2) handles these by converting the inner PAC aggregate to its _counters variant (returning all 64 per-sample values as LIST<DOUBLE>) and then applying one of three terminal wrappers depending on how the result is consumed.
When a PAC aggregate result appears in a projection expression (arithmetic over one or more aggregates), the rewriter builds a list_transform lambda that evaluates the expression across all 64 possible worlds, then reduces to a scalar with as_noised.
For expressions involving multiple aggregates, list_zip combines the counter lists so the lambda can access all values per world.
TPC-H Q08 illustrates this: the query computes SUM(CASE nation='BRAZIL' THEN volume ELSE 0 END) / SUM(volume) — a ratio of two aggregates:
-- Q08: as_noised wraps a projection expression over two aggregates
SELECT o_year,
CAST(as_noised(
list_transform(
list_zip(
list_transform(as_sum(pac_pu, brazil_volume),
lambda y: CAST(y AS DECIMAL(18,2))),
list_transform(as_sum(pac_pu, volume),
lambda y: CAST(y AS DECIMAL(18,2)))),
lambda x: CAST(x[1] / x[2] AS FLOAT))) AS FLOAT) AS mkt_share
FROM ...
GROUP BY o_yearThe inner list_transform calls cast counters back to their original types; the outer lambda computes the division for each of the 64 worlds; as_noised then reduces the 64-element list to a single noised scalar.
When a PAC aggregate result appears in a filter predicate and the tuples being filtered are not themselves subject to a PAC aggregate (i.e., no sensitive aggregation sits above the filter), the rewriter uses priv_filter. This evaluates the comparison across all 64 worlds and makes a probabilistic filtering decision (returns true with probability popcount(mask)/64).
The rewriter attempts algebraic simplification to emit specialized variants (priv_filter_gt, priv_filter_lt, etc.) that avoid the lambda overhead.
TPC-H Q20 illustrates this: the query filters partsupp rows where ps_availqty > 2 * (SELECT SUM(l_quantity) ...) — the filtered tuples (suppliers) are not themselves aggregated by PAC:
-- Q20: priv_filter wraps a comparison against a subquery aggregate
SELECT s_name, s_address
FROM supplier JOIN nation ON ...
WHERE s_suppkey IN (
SELECT ps_suppkey FROM partsupp
WHERE ps_partkey IN (SELECT p_partkey FROM part WHERE p_name LIKE 'forest%')
AND priv_filter_gt(ps_availqty * 2,
(SELECT as_sum(priv_hash(hash(o_custkey)), l_quantity)
FROM lineitem JOIN orders ON l_orderkey = o_orderkey
WHERE l_partkey = ps_partkey AND l_suppkey = ps_suppkey
AND l_shipdate >= DATE '1994-01-01'
AND l_shipdate < DATE '1995-01-01')))priv_filter_gt compares the non-sensitive value ps_availqty * 2 against each of the 64 counter values of as_sum and returns a UBIGINT mask; the row passes if the mask is non-zero (majority vote).
When a PAC aggregate result appears in a filter predicate and a PAC aggregate sits above the filter (i.e., the filtered tuples feed into a sensitive aggregation), priv_filter is insufficient — the outer aggregate must know which of the 64 worlds each tuple belongs to. The rewriter uses priv_select, which AND's the 64-bit filter mask with the outer hash, creating a new hash that encodes both the sub-sampling and the categorical decision.
The rewriter emits specialized variants (priv_select_lt, priv_select_gt, etc.) when possible.
TPC-H Q17 illustrates this: the query computes SUM(l_extendedprice) / 7.0 over lineitems where l_quantity < 0.2 * AVG(l_quantity) — the filter depends on a PAC aggregate (inner AVG), and the filtered result feeds into another PAC aggregate (outer SUM):
-- Q17: priv_select wraps a filter that feeds into an outer PAC aggregate
SELECT as_noised_sum(pac_pu, l_extendedprice) / 7.0 AS avg_yearly
FROM (SELECT priv_select_lt(
priv_hash(hash(o_custkey)),
lineitem.l_quantity * 5,
(SELECT priv_div(
as_sum(priv_hash(hash(o_sub.o_custkey)), l_sub.l_quantity),
as_count(priv_hash(hash(o_sub.o_custkey)), l_sub.l_quantity))
FROM lineitem AS l_sub JOIN orders AS o_sub ON ...
WHERE l_sub.l_partkey = part.p_partkey)) AS pac_pu,
l_extendedprice
FROM lineitem JOIN part ON ... JOIN orders ON ...
WHERE part.p_brand = 'Brand#23' AND part.p_container = 'MED BOX')
WHERE pac_pu <> 0priv_select_lt takes the outer hash, the non-sensitive comparand (l_quantity * 5), and the inner aggregate's counter list. It returns a new UBIGINT hash where bit j is set only if both the original sub-sample includes the tuple AND the comparison holds in world j. The outer as_noised_sum then aggregates using this combined hash. The WHERE pac_pu <> 0 applies majority-vote filtering.