CTEs & Subqueries Questions
Common Table Expressions (CTEs) and subqueries in SQL, including syntax, recursive CTEs, usage patterns, performance implications, and techniques for writing clear, efficient queries. Covers when to use CTEs versus subqueries, refactoring patterns, and potential pitfalls.
MediumTechnical
33 practiced
You're asked to pre-aggregate product sales for a BI dashboard to reduce query time on a 100M-row fact table. Describe using CTEs as part of a pipeline to create incremental, partitioned pre-aggregations and show a sample SQL pattern for the incremental step using `WITH` to stage new rows before merging into the aggregate table.
Sample Answer
Approach: build an incremental pipeline that (1) identifies new/changed fact rows since last run, (2) stages them with CTEs to compute per-key deltas, and (3) MERGEs those deltas into a partitioned pre-aggregate table. Use CTEs (WITH) to make the incremental step readable and auditable.Sample SQL (Postgres/BigQuery-style; adjust MERGE syntax per DB):Key reasoning:- CTEs separate "identify", "fetch existing", and "compute delta" steps for clarity and replayability.- Partition by month (partition_date) to limit I/O and make incremental runs fast.- Use deterministic last_processed_ts (or CDC watermark) to avoid reprocessing; keep idempotency by tracking updated_at.- Consider vacuum/compact for partitions and run full refresh for recent N days if CDC is noisy.Edge cases:- Late-arriving corrections: either backfill affected partitions or use a longer watermark.- Duplicate facts: dedupe in recent_facts by unique sale_id.- Concurrency: serialize runs per partition or use transactional MERGE to avoid race conditions.
sql
-- Assume preagg.product_sales_monthly(partition_date, product_id, qty, revenue, updated_at)
WITH
-- A: recent source rows since last_processed_ts (parameter)
recent_facts AS (
SELECT product_id,
DATE_TRUNC('month', sale_ts) AS partition_date,
SUM(quantity) AS qty,
SUM(quantity * price) AS revenue,
MAX(updated_at) AS max_updated_at
FROM fact_sales
WHERE sale_ts >= @last_processed_ts
GROUP BY 1,2
),
-- B: existing aggregate rows for affected partitions/keys
existing_agg AS (
SELECT product_id, partition_date, qty, revenue
FROM preagg.product_sales_monthly
WHERE partition_date IN (SELECT DISTINCT partition_date FROM recent_facts)
),
-- C: compute merged values (delta upsert logic)
deltas AS (
SELECT r.product_id,
r.partition_date,
COALESCE(e.qty,0) + r.qty AS new_qty,
COALESCE(e.revenue,0) + r.revenue AS new_revenue,
r.max_updated_at
FROM recent_facts r
LEFT JOIN existing_agg e
ON r.product_id = e.product_id
AND r.partition_date = e.partition_date
)
-- Final: atomic merge into partitioned pre-aggregate table
MERGE INTO preagg.product_sales_monthly AS tgt
USING deltas AS src
ON tgt.product_id = src.product_id
AND tgt.partition_date = src.partition_date
WHEN MATCHED THEN
UPDATE SET qty = src.new_qty, revenue = src.new_revenue, updated_at = src.max_updated_at
WHEN NOT MATCHED THEN
INSERT (partition_date, product_id, qty, revenue, updated_at)
VALUES (src.partition_date, src.product_id, src.new_qty, src.new_revenue, src.max_updated_at);MediumTechnical
32 practiced
Show a SQL pattern using CTEs that handles late-arriving events in an ETL merge process. Assume `staging_events` contains new and updated rows and `events` is the target table. Provide pseudocode for staging, deduping, and MERGE/UPSERT (Snowflake or Postgres) using a `WITH` clause.
Sample Answer
To handle late-arriving events in an ETL merge, use CTEs to (1) normalize staging, (2) dedupe by business key keeping the latest event by event_timestamp (and tie-breaker arrival_ts), and (3) perform a MERGE/UPSERT into target. Below is a clear pattern that works for Snowflake (MERGE) and can be adapted to Postgres using INSERT ... ON CONFLICT.Key reasoning:- Deduping by (event_timestamp, arrival_ts) ensures late-arriving but newer events (by event time) replace older records.- Join to target to compare timestamps and avoid overwriting with older data.- processed_ts gives auditability.- For Postgres <15, replace MERGE with: - INSERT ... ON CONFLICT (event_id) DO UPDATE SET ... WHERE EXCLUDED.event_timestamp > events.event_timestamp.
-- 1) Normalize & mark source metadata, 2) Deduplicate staging keeping latest event per business key,
-- 3) MERGE into target table (events) updating if newer or inserting if new.
WITH staged AS (
SELECT
se.event_id,
se.user_id,
se.event_type,
se.event_timestamp, -- actual event time (may be late)
se.arrival_ts, -- when row landed in staging
se.payload,
CURRENT_TIMESTAMP() AS processed_ts
FROM staging_events se
WHERE se.event_timestamp IS NOT NULL
),
deduped AS (
-- Keep the single best row per event_id (or business key)
SELECT DISTINCT ON (event_id) *
FROM staged
ORDER BY event_id, event_timestamp DESC, arrival_ts DESC
),
upsert_source AS (
-- enrich deduped rows with target lookup to decide action
SELECT
d.*,
t.event_timestamp AS target_event_ts,
t.payload AS target_payload
FROM deduped d
LEFT JOIN events t
ON t.event_id = d.event_id
)
-- Snowflake/Postgres (Postgres >=15 supports MERGE). For Postgres <15, use INSERT ... ON CONFLICT.
MERGE INTO events AS tgt
USING upsert_source AS src
ON tgt.event_id = src.event_id
WHEN MATCHED AND (src.event_timestamp > tgt.event_timestamp OR tgt.event_timestamp IS NULL) THEN
UPDATE SET
user_id = src.user_id,
event_type = src.event_type,
event_timestamp = src.event_timestamp,
payload = src.payload,
updated_at = src.processed_ts
WHEN NOT MATCHED THEN
INSERT (event_id, user_id, event_type, event_timestamp, payload, created_at)
VALUES (src.event_id, src.user_id, src.event_type, src.event_timestamp, src.payload, src.processed_ts);MediumTechnical
29 practiced
Discuss the benefits and pitfalls of using CTEs for readability and maintainability in BI SQL code. Provide two real-world examples: one where CTEs make a long metric pipeline clear, and one where CTEs hide expensive operations and caused production issues.
Sample Answer
Common Table Expressions (CTEs) improve readability and maintainability in BI SQL by breaking complex logic into named, composable steps. They let you document intent (name = business meaning), avoid repeated subqueries, and make debugging easier because you can run each CTE independently. For BI analysts—who frequently translate business metrics into SQL—CTEs align code with business-language steps (e.g., raw_events → deduped_sessions → user_metrics → final_report).Benefits- Self-documenting: names act as inline documentation.- Modular: easier to test, refactor, and reuse steps.- Debug-friendly: run intermediate CTEs to validate data.- Cleaner joins/filters: reduces nested subqueries and indentation chaos.Pitfalls- Performance surprises: some engines inline or re-evaluate CTEs, leading to repeated expensive scans unless materialized.- Hidden cost: long chains can obscure expensive aggregations or cross-joins.- Overuse: many small CTEs can fragment logic and harm readability.- Dependency complexity: deep chains make impact analysis harder when changing upstream logic.Positive example (clarifying a long metric pipeline)Situation: Monthly LTV required user cohorting, event dedup, revenue attribution, and churn adjustment.Approach: I wrote sequential CTEs: cohort_users, first_purchase, revenue_by_period, churn_flags, ltv_calc — each named for a business step. Result: non-technical PMs could follow the logic, QA validated each step, and future changes (e.g., attribution window) were isolated to one CTE.Negative example (hidden expensive op caused production issue)Situation: A daily dashboard started timing out after a dataset growth.Cause: I had a CTE that performed a full table cross join and heavy window aggregation; because the warehouse (older Presto) inlined CTEs, the expensive step executed multiple times in the plan.Impact: Nightly refreshes exceeded SLA and incurred compute costs.Lesson: I changed that CTE to a temporary/materialized table (or used WITH MATERIALIZED when supported), added indexes/partition filters, and pushed aggregation earlier. After that, runtime stabilized.Best practices- Name CTEs with business intent.- Keep CTEs focused (single responsibility).- Test intermediate outputs.- Monitor query plans; materialize expensive steps if engine re-evaluates CTEs.- Add comments about performance assumptions and expected row counts.This balance preserves readability while avoiding hidden costs—critical for reliable BI pipelines.
MediumTechnical
35 practiced
Write SQL using CTEs to compute weekly retention for user cohorts defined by signup week. For each cohort, show the number of users active in week 0 through week 12. Use window functions where helpful and explain how you would visualize this in a dashboard.
Sample Answer
Approach: define cohorts by signup_week (week start date), compute each user’s active week offset relative to their cohort (week 0 = signup week), aggregate unique users per cohort × week 0..12 using CTEs and window functions to pivot or produce columns for dashboard.Key points:- week_offset uses epoch difference divided by 7 days to avoid calendar-week edge issues.- Deduplicate per user-week so multiple events count once.Visualization recommendations:- Primary: Cohort heatmap (signup_week rows × week offsets columns) showing retention % with color scale; add cohort_size as row label.- Secondary: Line chart for selected cohorts comparing retention curves (0–12).- Add filters for signup date range, user segment, and conversion events.- Provide tooltips with raw counts and rates; sort cohorts newest→oldest and freeze week 0 column.
sql
WITH users AS (
-- base table: user_id, signup_at, event_at (activity timestamp)
SELECT user_id,
DATE_TRUNC('week', signup_at)::date AS signup_week,
DATE_TRUNC('week', event_at)::date AS event_week
FROM user_events
WHERE event_at IS NOT NULL
),
cohort_weeks AS (
-- compute week offset per activity
SELECT
user_id,
signup_week,
event_week,
DATE_PART('week', event_week::timestamp) -- not used directly; use weeks_between
AS dummy,
FLOOR(EXTRACT(EPOCH FROM (event_week - signup_week)) / (7*24*3600))::int AS week_offset
FROM users
),
filtered AS (
-- only week 0..12 and dedupe per user-week (active if any activity)
SELECT DISTINCT user_id, signup_week, week_offset
FROM cohort_weeks
WHERE week_offset BETWEEN 0 AND 12
),
cohort_sizes AS (
-- total users in each cohort (signup week)
SELECT signup_week, COUNT(DISTINCT user_id) AS cohort_size
FROM users
GROUP BY signup_week
),
retention AS (
-- active users per cohort-week
SELECT
f.signup_week,
f.week_offset,
COUNT(DISTINCT f.user_id) AS active_users
FROM filtered f
GROUP BY f.signup_week, f.week_offset
),
retention_pivot AS (
-- pivot weeks into columns 0..12
SELECT
r.signup_week,
c.cohort_size,
MAX(CASE WHEN week_offset = 0 THEN active_users END) AS w0,
MAX(CASE WHEN week_offset = 1 THEN active_users END) AS w1,
MAX(CASE WHEN week_offset = 2 THEN active_users END) AS w2,
MAX(CASE WHEN week_offset = 3 THEN active_users END) AS w3,
MAX(CASE WHEN week_offset = 4 THEN active_users END) AS w4,
MAX(CASE WHEN week_offset = 5 THEN active_users END) AS w5,
MAX(CASE WHEN week_offset = 6 THEN active_users END) AS w6,
MAX(CASE WHEN week_offset = 7 THEN active_users END) AS w7,
MAX(CASE WHEN week_offset = 8 THEN active_users END) AS w8,
MAX(CASE WHEN week_offset = 9 THEN active_users END) AS w9,
MAX(CASE WHEN week_offset = 10 THEN active_users END) AS w10,
MAX(CASE WHEN week_offset = 11 THEN active_users END) AS w11,
MAX(CASE WHEN week_offset = 12 THEN active_users END) AS w12
FROM retention r
JOIN cohort_sizes c USING (signup_week)
GROUP BY r.signup_week, c.cohort_size
ORDER BY signup_week DESC
)
SELECT
signup_week,
cohort_size,
w0, w1, w2, w3, w4, w5, w6, w7, w8, w9, w10, w11, w12,
-- retention rates as percentages (optional)
ROUND(100.0 * COALESCE(w0,0) / cohort_size, 2) AS pct_w0,
ROUND(100.0 * COALESCE(w1,0) / cohort_size, 2) AS pct_w1,
ROUND(100.0 * COALESCE(w12,0) / cohort_size, 2) AS pct_w12
FROM retention_pivot;HardTechnical
29 practiced
Here is an example EXPLAIN ANALYZE snippet for a Postgres query that uses a CTE:Seq Scan on orders (cost=0.00..10000.00 rows=1000000 width=40) CTE Scan on cte_customers (cost=100.00..500.00 rows=10000 width=50)Given this simplified output, identify likely bottlenecks and propose three concrete optimizations (indexes, rewrite CTE, partitioning) to reduce runtime for a BI report that times out.
Sample Answer
Diagnosis (why it’s slow)- Seq Scan on orders with huge cost and rows=1,000,000 indicates a full-table scan: likely the report filters on a column that isn’t indexed (or cardinality estimates are off).- CTE Scan on cte_customers with non-trivial cost suggests the CTE is being materialized or scanned repeatedly (Postgres <= v11 treats CTEs as optimization fences), multiplying work for the join/report.- Together this implies heavy I/O and unnecessary re-processing of large sets.Three concrete optimizations1) Add targeted indexes (reduce Seq Scan)- Identify filter/join columns used by the report (e.g., orders.order_date, orders.customer_id, orders.status).- Example:- Why: enables index scans or index-only scans for selective predicates, reduces rows read and I/O.2) Rewrite or control CTE materialization- If using Postgres <= 11 or the CTE is forcing materialization, inline the CTE as a subquery or use JOIN to allow planner optimizations:- Or explicitly force inlining in Postgres 12+ with NOT MATERIALIZED:- Why: avoids repeated materialization and lets the planner push filters into the customer subquery for better join plans.3) Partition orders by date (reduce scanned data for BI windows)- Partition large orders table by range on order_date (monthly/quarterly depending on query patterns):- Why: partition pruning will skip irrelevant partitions for date-bounded reports, drastically reducing scanned rows and runtime for common BI time-window queries.Additional quick wins- Run ANALYZE after changes so planner has up-to-date stats.- Consider covering indexes (include columns) for index-only scans:- Monitor with EXPLAIN ANALYZE after each change and prioritize by wall-time reduction.
sql
CREATE INDEX ON orders (order_date DESC);
CREATE INDEX ON orders (customer_id);sql
-- Instead of WITH cte_customers AS (...) SELECT ... FROM orders JOIN cte_customers USING (customer_id)
-- Inline
SELECT ... FROM orders o JOIN (
SELECT customer_id, segment FROM customers WHERE active
) c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2025-01-01';sql
WITH cte_customers AS MATERIALIZED (...) -- or NOT MATERIALIZEDsql
CREATE TABLE orders_y2025_q1 PARTITION OF orders FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');sql
CREATE INDEX ON orders (order_date) INCLUDE (total_amount, status);Unlock Full Question Bank
Get access to hundreds of CTEs & Subqueries interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.