Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Query Rewriting with Common Subexpression Elimination: CTEs, Derived Tables, and Optimizer Hints for Maximum SQL Performance

Query Rewriting with Common Subexpression Elimination: CTEs, Derived Tables, and Optimizer Hints for Maximum SQL Performance

SQL🔥 Expert28 min readAug 13, 2026Updated Aug 13, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • The Problem: What Repeated Subexpressions Actually Cost
  • Understanding How SQL Engines Handle Repeated Logic
  • The Logical vs. Physical Execution Gap
  • How Different Engines Handle CTEs
  • Technique 1: CTEs with Explicit Materialization
  • Step 1: Identify All Repeated Computations
  • Step 2: Factor into CTEs
  • The `NOT MATERIALIZED` Escape Hatch
  • Technique 2: Derived Tables and Inline Views
  • When Derived Tables Are the Right Choice
  • Technique 3: Temporary Tables for Cross-Query and Session-Level CSE
  • Technique 4: Indexed Views and Materialized Views
  • Reading Execution Plans to Verify Your Refactoring
  • PostgreSQL: EXPLAIN (ANALYZE, BUFFERS)
  • SQL Server: SET STATISTICS IO ON
  • BigQuery: Job Execution Details
  • Optimizer Hints: Precision Instruments, Not Magic
  • PostgreSQL: The `enable_*` GUC Parameters
  • SQL Server: OPTION(RECOMPILE) and Query Store Hints
  • Oracle: NO_MERGE and MATERIALIZE Hints
  • Advanced Pattern: Cascading CTEs for Multi-Level CSE
  • When CSE Makes Things Worse: Anti-Patterns
  • Anti-Pattern 1: Materializing Large Results You Then Filter Heavily
  • Anti-Pattern 2: CTEs That Disable Useful Optimizer Transformations
  • Anti-Pattern 3: Over-Factoring for Readability at the Cost of Performance
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • "My CTE isn't being materialized even with MATERIALIZED keyword"
  • "My temp table approach is slower than the original query"
  • "The execution plan shows my CTE being scanned multiple times"
  • "SQL Server keeps expanding my CTEs no matter what"
  • "My query is faster after refactoring but produces different results"
  • Summary & Next Steps
  • Query Rewriting with Common Subexpression Elimination: Factoring Repeated Logic Using CTEs, Derived Tables, and Optimizer Hints for Maximum Execution Efficiency

    Introduction

    You've written a query that works. It returns the right numbers, passes QA, and gets deployed to production. Six months later, a business analyst notices the dashboard is taking 45 seconds to load. You open the query and find it: the same 200-line subquery computing rolling 30-day revenue figures, pasted three times across different parts of the WHERE clause, the SELECT list, and a HAVING filter. Each copy is evaluated independently by the database engine. You've inadvertently written a query that does three times as much work as it needs to.

    This isn't a contrived scenario. It happens constantly in production SQL at organizations of every size, and the pattern has a name in compiler theory: common subexpression elimination (CSE). Compilers for general-purpose programming languages have done this automatically for decades. SQL query optimizers sometimes do it, but the guarantees are weak, inconsistent across database engines, and often sabotaged by the very way we structure our queries. The good news is that you don't have to wait for the optimizer — you can do it yourself, explicitly, using CTEs, derived tables, and a precise understanding of how your execution engine handles repeated logic.

    By the end of this lesson, you'll understand not just how to factor repeated SQL logic, but why different approaches produce different execution plans, when the optimizer helps you and when it actively works against you, and how to use optimizer hints as a precision instrument rather than a blunt force tool.

    What you'll learn:

    • What common subexpression elimination means in a SQL context and why naive repetition is expensive
    • How CTEs materialize (or don't) in major database engines and the execution plan implications of each behavior
    • When to use CTEs vs. derived tables vs. temp tables for subexpression factoring, and why the choice matters
    • How to read execution plans to verify your refactoring actually reduced work
    • How to use optimizer hints (MATERIALIZE, NO_MERGE, WITH (NOEXPAND), and engine-specific directives) to enforce materialization when the optimizer makes the wrong call

    Prerequisites

    This lesson assumes you're comfortable with:

    • Writing complex multi-join queries with aggregation and window functions
    • Reading basic execution plans (EXPLAIN output or visual plan diagrams)
    • Understanding the difference between logical and physical query execution
    • Basic familiarity with at least one major RDBMS (PostgreSQL, SQL Server, MySQL 8+, or BigQuery)

    If you've never looked at an EXPLAIN plan before, spend an hour on that first. Everything in this lesson becomes much more concrete once you can see what the engine is actually doing.


    The Problem: What Repeated Subexpressions Actually Cost

    Let's build a concrete example. You're working for a SaaS company. You have an orders table with about 50 million rows, a customers table, and a products table. A business analyst needs a report that flags customers who:

    1. Have lifetime value above the 90th percentile
    2. Made a purchase in the last 30 days
    3. Have an average order value higher than the category average for their primary product category

    Here's the naive implementation a lot of people write first:

    SELECT
        c.customer_id,
        c.email,
        c.signup_date,
        -- Compute lifetime value inline
        (
            SELECT SUM(o.order_total)
            FROM orders o
            WHERE o.customer_id = c.customer_id
        ) AS lifetime_value,
        -- Flag for 90th percentile
        CASE WHEN (
            SELECT SUM(o.order_total)
            FROM orders o
            WHERE o.customer_id = c.customer_id
        ) >= (
            SELECT PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY ltv)
            FROM (
                SELECT customer_id, SUM(order_total) AS ltv
                FROM orders
                GROUP BY customer_id
            ) ltv_calc
        ) THEN 1 ELSE 0 END AS is_high_value
    FROM customers c
    WHERE
        -- Recent purchase check
        EXISTS (
            SELECT 1 FROM orders o
            WHERE o.customer_id = c.customer_id
            AND o.order_date >= CURRENT_DATE - INTERVAL '30 days'
        )
        AND
        -- Lifetime value above 90th percentile (again)
        (
            SELECT SUM(o.order_total)
            FROM orders o
            WHERE o.customer_id = c.customer_id
        ) >= (
            SELECT PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY ltv)
            FROM (
                SELECT customer_id, SUM(order_total) AS ltv
                FROM orders
                GROUP BY customer_id
            ) ltv_calc
        );
    

    Count the redundant computations:

    • The per-customer SUM(order_total) appears three times — once in the SELECT list, once in the CASE, and once in the WHERE clause
    • The entire 90th percentile calculation over all customers appears twice
    • The full table scan of orders to build the ltv_calc subquery happens twice for the percentile calculation alone

    On a 50-million-row orders table, you're potentially doing 5–7 full or large partial scans where 2 would suffice. That's not an optimization opportunity — it's a correctness and efficiency emergency.

    Why doesn't the optimizer fix this automatically? Modern query optimizers can recognize some repeated subexpressions, but their ability to do so is highly constrained. Correlated subqueries (like the per-customer SUM above) are particularly hard for optimizers to memoize because each invocation potentially produces different results. Scalar subqueries in both SELECT and WHERE positions may be executed separately even if they're textually identical. Never assume the optimizer will handle this.


    Understanding How SQL Engines Handle Repeated Logic

    Before we fix the query, we need to understand what's actually happening under the hood — because this knowledge will inform every technique we use.

    The Logical vs. Physical Execution Gap

    SQL is a declarative language. You specify what you want, not how to compute it. The optimizer's job is to translate your logical request into an efficient physical execution plan. The optimizer does this by applying transformation rules — algebraic equivalences that preserve result correctness while changing execution strategy.

    Common subexpression elimination is one such transformation. In compiler theory, it means: "if you compute the same value twice, compute it once and reuse the result." For SQL, this requires the optimizer to:

    1. Recognize that two subexpressions are semantically equivalent
    2. Determine that computing them once and caching the result is cheaper than computing them independently
    3. Choose an appropriate materialization strategy (in-memory hash table, temp table, spool)

    Step 1 is harder than it sounds. SUM(order_total) WHERE customer_id = c.customer_id appears in three places but the optimizer must prove they reference the same outer binding. Step 2 involves cost estimation, which depends on statistics. Step 3 involves memory and I/O trade-offs.

    How Different Engines Handle CTEs

    This is the most important thing to understand before using CTEs for CSE, because CTE behavior differs dramatically between database engines:

    PostgreSQL (before version 12): CTEs were always optimization fences. The planner would materialize every CTE result into a temporary structure and never push predicates into them. This was actually useful for CSE — materialization meant the subquery ran exactly once.

    PostgreSQL 12+: The planner can now "inline" CTEs (treat them like view definitions and push them into the larger query). By default, a CTE is inlined if it's referenced once; if referenced multiple times, PostgreSQL may still materialize it. You can force materialization with MATERIALIZED keyword or prevent it with NOT MATERIALIZED.

    SQL Server: SQL Server generally treats CTEs as syntactic sugar — it expands them inline during query compilation. A CTE referenced three times may result in the underlying computation executing three times. To force single-execution, you often need #temp tables.

    MySQL 8+: Similar to SQL Server — CTEs are typically inlined. Multiple references can mean multiple executions.

    BigQuery: CTEs are expanded unless the query planner decides to materialize them. The WITH clause in BigQuery is better thought of as a readability tool than a materialization guarantee.

    Oracle: Oracle's behavior is similar to SQL Server — CTEs are generally inlined, though the optimizer may cache results in some circumstances.

    The critical insight: Never assume that writing something once as a CTE means it executes once. You must understand your engine's specific behavior and verify with execution plans.


    Technique 1: CTEs with Explicit Materialization

    The cleanest approach to CSE in SQL is factoring the repeated expression into a CTE and, where necessary, forcing materialization. Let's rewrite our problem query.

    Step 1: Identify All Repeated Computations

    Before writing a single line of SQL, annotate what's repeated:

    • Computation A: Per-customer lifetime value SUM(order_total) GROUP BY customer_id — referenced 3 times
    • Computation B: 90th percentile of lifetime value across all customers — referenced 2 times
    • Computation C: Customers with recent orders — referenced 1 time (but benefits from being separated)

    Step 2: Factor into CTEs

    WITH
    -- Computation A: Per-customer lifetime value (runs once)
    customer_ltv AS MATERIALIZED (
        SELECT
            customer_id,
            SUM(order_total)    AS lifetime_value,
            COUNT(*)            AS order_count,
            AVG(order_total)    AS avg_order_value,
            MAX(order_date)     AS last_order_date
        FROM orders
        GROUP BY customer_id
    ),
    
    -- Computation B: Portfolio-level percentile thresholds (runs once)
    ltv_thresholds AS MATERIALIZED (
        SELECT
            PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY lifetime_value) AS p90_ltv,
            PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY lifetime_value) AS p75_ltv
        FROM customer_ltv  -- References the already-computed CTE
    ),
    
    -- Computation C: Customers active in last 30 days
    recently_active AS MATERIALIZED (
        SELECT DISTINCT customer_id
        FROM orders
        WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
    ),
    
    -- Computation D: Category average order values
    category_avg AS MATERIALIZED (
        SELECT
            p.category,
            AVG(o.order_total) AS category_avg_order_value
        FROM orders o
        JOIN products p ON o.product_id = p.product_id
        GROUP BY p.category
    )
    
    SELECT
        c.customer_id,
        c.email,
        c.signup_date,
        ltv.lifetime_value,
        ltv.order_count,
        ltv.avg_order_value,
        CASE
            WHEN ltv.lifetime_value >= thresh.p90_ltv THEN 'Tier 1'
            WHEN ltv.lifetime_value >= thresh.p75_ltv THEN 'Tier 2'
            ELSE 'Standard'
        END AS value_tier
    FROM customers c
    JOIN customer_ltv ltv ON c.customer_id = ltv.customer_id
    JOIN ltv_thresholds thresh ON TRUE  -- cross join to scalar result
    JOIN recently_active ra ON c.customer_id = ra.customer_id
    JOIN category_avg ca
        ON ca.category = c.primary_category  -- assuming customers have a primary category
    WHERE
        ltv.lifetime_value >= thresh.p90_ltv
        AND ltv.avg_order_value > ca.category_avg_order_value;
    

    The MATERIALIZED keyword (PostgreSQL 12+ syntax) tells the planner: compute this CTE once, store the result, and reuse it for all references. The result is:

    • One scan of orders for customer_ltv (instead of three)
    • One pass through customer_ltv for ltv_thresholds (instead of two)
    • One scan of orders for recently_active
    • One scan of orders + products for category_avg

    We've gone from 7+ scans of large tables to 3 scans and some joins. On a 50-million-row table, this is a qualitative difference, not just a marginal improvement.

    The `NOT MATERIALIZED` Escape Hatch

    Sometimes you want the opposite — you want the optimizer to push predicates into a CTE rather than materializing it. Consider:

    WITH all_orders AS NOT MATERIALIZED (
        SELECT *
        FROM orders
        JOIN order_line_items oli ON orders.order_id = oli.order_id
    )
    SELECT * FROM all_orders WHERE customer_id = 12345;
    

    With NOT MATERIALIZED, the planner can push the WHERE customer_id = 12345 predicate down into the CTE definition, potentially using an index on customer_id. If you force materialization here, you'd scan and store the entire join result before filtering.

    Rule of thumb: Use MATERIALIZED when the CTE is referenced multiple times or when its computation is expensive and selectivity happens after it. Use NOT MATERIALIZED when the CTE is referenced once and you want predicate pushdown.


    Technique 2: Derived Tables and Inline Views

    Before CTEs existed (or in engines where CTE behavior is unreliable), derived tables — subqueries in the FROM clause — were the standard tool for CSE. They're still valuable, particularly when you need precise control over join order or when you're working in MySQL pre-8.0.

    The key property of a derived table is that it's evaluated in place, and — critically — each reference to the same derived table definition creates a separate evaluation. You can't reference a derived table by name twice (unlike a CTE). However, you can nest derived tables to achieve multi-level CSE.

    -- Instead of referencing customer_ltv twice,
    -- we structure the query so the derived table is used once
    -- and the result flows through to all uses
    
    SELECT
        c.customer_id,
        c.email,
        ltv_with_tier.lifetime_value,
        ltv_with_tier.value_tier
    FROM customers c
    JOIN (
        -- This derived table encapsulates LTV computation AND tier assignment
        -- so the downstream query doesn't need to re-reference LTV multiple times
        SELECT
            ltv.customer_id,
            ltv.lifetime_value,
            CASE
                WHEN ltv.lifetime_value >= thresh.p90_ltv THEN 'Tier 1'
                WHEN ltv.lifetime_value >= thresh.p75_ltv THEN 'Tier 2'
                ELSE 'Standard'
            END AS value_tier
        FROM (
            -- Inner derived table: customer LTV
            SELECT customer_id, SUM(order_total) AS lifetime_value
            FROM orders
            GROUP BY customer_id
        ) ltv
        CROSS JOIN (
            -- Inner derived table: thresholds
            -- References a *separate* evaluation of the LTV query
            -- This is the weakness of derived tables vs. CTEs
            SELECT
                PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY total_ltv) AS p90_ltv,
                PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY total_ltv) AS p75_ltv
            FROM (
                SELECT customer_id, SUM(order_total) AS total_ltv
                FROM orders
                GROUP BY customer_id
            ) ltv_for_percentiles
        ) thresh
    ) ltv_with_tier ON c.customer_id = ltv_with_tier.customer_id
    WHERE ltv_with_tier.value_tier = 'Tier 1';
    

    Notice the problem: even with derived tables, we still scan orders twice because derived tables can't share computation between sibling subqueries. This is exactly where CTEs with materialization win.

    When Derived Tables Are the Right Choice

    Derived tables genuinely outperform CTEs in specific situations:

    1. When predicate pushdown is critical: If a derived table wraps a large table and you're filtering heavily, the planner can often push the outer WHERE into the derived table definition. Most planners will not push predicates into a materialized CTE.

    2. When you're on an engine that materializes CTEs poorly: In SQL Server, a derived table joined once is often cleaner than a CTE that might get expanded and re-evaluated.

    3. When you need to control join order explicitly: Some planners treat derived tables as atomic units in join ordering; you can use this to pin an expensive small-result computation before it's joined to a large table.


    Technique 3: Temporary Tables for Cross-Query and Session-Level CSE

    Sometimes the repeated computation isn't within a single query — it's across multiple queries in a session, a stored procedure, or a batch job. CTEs are scoped to a single statement; derived tables obviously can't escape their enclosing query. Temporary tables are the tool for this level of CSE.

    -- In a stored procedure or ETL script:
    
    -- Step 1: Materialize the expensive computation once
    CREATE TEMPORARY TABLE tmp_customer_ltv AS
    SELECT
        customer_id,
        SUM(order_total)    AS lifetime_value,
        COUNT(*)            AS order_count,
        AVG(order_total)    AS avg_order_value,
        MAX(order_date)     AS last_order_date,
        MIN(order_date)     AS first_order_date
    FROM orders
    GROUP BY customer_id;
    
    -- Create an index for subsequent joins
    CREATE INDEX idx_tmp_ltv_customer ON tmp_customer_ltv(customer_id);
    CREATE INDEX idx_tmp_ltv_value ON tmp_customer_ltv(lifetime_value);
    
    -- Step 2: Use the temp table across multiple subsequent queries
    -- Query A: High-value customer report
    SELECT c.*, t.lifetime_value, t.order_count
    FROM customers c
    JOIN tmp_customer_ltv t ON c.customer_id = t.customer_id
    WHERE t.lifetime_value >= 10000;
    
    -- Query B: Churn risk analysis (using same temp table)
    SELECT c.*, t.last_order_date,
           CURRENT_DATE - t.last_order_date AS days_since_last_order
    FROM customers c
    JOIN tmp_customer_ltv t ON c.customer_id = t.customer_id
    WHERE t.last_order_date < CURRENT_DATE - INTERVAL '90 days'
    AND t.lifetime_value >= 500;  -- Not just any churner — valuable ones
    
    -- Query C: Cohort analysis
    SELECT
        DATE_TRUNC('month', t.first_order_date) AS cohort_month,
        COUNT(*) AS customers,
        AVG(t.lifetime_value) AS avg_ltv,
        AVG(t.order_count) AS avg_orders
    FROM tmp_customer_ltv t
    GROUP BY 1
    ORDER BY 1;
    
    -- Cleanup
    DROP TEMPORARY TABLE tmp_customer_ltv;
    

    The temp table approach has three significant advantages over CTEs for multi-query scenarios:

    1. True single materialization: The data is computed once, written to disk or memory buffer, and read cheaply for each subsequent query.
    2. Indexability: You can add indexes to a temp table. You cannot index a CTE result.
    3. Statistics: Many engines gather statistics on temp tables, which helps the optimizer make better join decisions downstream.

    SQL Server-specific note: In SQL Server, #temp tables are almost always preferable to CTEs for repeated computation in stored procedures. The SQL Server optimizer regularly makes poor decisions with complex CTEs — it often expands them and produces bad cardinality estimates. A #temp table with UPDATE STATISTICS forces good estimation.


    Technique 4: Indexed Views and Materialized Views

    For truly expensive computations that are needed repeatedly — not just within a session, but across many sessions and users — the right answer is to pre-materialize the subexpression at the schema level using indexed views (SQL Server) or materialized views (PostgreSQL, Oracle, MySQL 8+, BigQuery).

    -- PostgreSQL: Create a materialized view for customer LTV
    -- This is the "ultimate" CSE — compute once, refresh on schedule
    
    CREATE MATERIALIZED VIEW mv_customer_ltv AS
    SELECT
        o.customer_id,
        SUM(o.order_total)      AS lifetime_value,
        COUNT(*)                AS order_count,
        AVG(o.order_total)      AS avg_order_value,
        MAX(o.order_date)       AS last_order_date,
        MIN(o.order_date)       AS first_order_date,
        COUNT(DISTINCT DATE_TRUNC('month', o.order_date)) AS active_months
    FROM orders o
    GROUP BY o.customer_id
    WITH DATA;
    
    CREATE UNIQUE INDEX ON mv_customer_ltv(customer_id);
    CREATE INDEX ON mv_customer_ltv(lifetime_value);
    CREATE INDEX ON mv_customer_ltv(last_order_date);
    
    -- Now any query can reference this view without recomputing
    SELECT c.*, m.lifetime_value, m.last_order_date
    FROM customers c
    JOIN mv_customer_ltv m ON c.customer_id = m.customer_id
    WHERE m.lifetime_value >= 5000;
    

    The materialized view is refreshed on a schedule:

    -- Refresh nightly (or incrementally if supported)
    REFRESH MATERIALIZED VIEW CONCURRENTLY mv_customer_ltv;
    

    SQL Server's indexed views go even further — with the NOEXPAND hint, the optimizer can automatically substitute the indexed view for matching subexpressions in queries, even when the query doesn't explicitly reference the view:

    -- SQL Server: Force optimizer to use indexed view
    SELECT c.customer_id, c.email, v.lifetime_value
    FROM customers c
    JOIN dbo.vw_customer_ltv v WITH (NOEXPAND) ON c.customer_id = v.customer_id
    WHERE v.lifetime_value >= 10000;
    

    WITH (NOEXPAND) tells SQL Server: don't expand this view into its definition — read the pre-computed index directly. Without this hint, even when an indexed view exists, the optimizer may choose to recompute from base tables.


    Reading Execution Plans to Verify Your Refactoring

    Writing the refactored query is only half the job. You need to prove it reduced work. Here's how to read plans for CSE evidence.

    PostgreSQL: EXPLAIN (ANALYZE, BUFFERS)

    EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
    WITH customer_ltv AS MATERIALIZED (
        SELECT customer_id, SUM(order_total) AS lifetime_value
        FROM orders
        GROUP BY customer_id
    )
    SELECT c.customer_id, ltv.lifetime_value
    FROM customers c
    JOIN customer_ltv ltv ON c.customer_id = ltv.customer_id
    WHERE ltv.lifetime_value > 5000;
    

    In the output, look for:

    • CTE Scan on customer_ltv — this means the CTE was materialized and is being read from the in-memory/on-disk result, not recomputed
    • Buffers: shared hit=X — lower numbers confirm fewer table pages were read
    • actual rows=X loops=1 on the CTE node — if loops > 1, the CTE was re-evaluated

    If you see Seq Scan on orders appear multiple times, your CTE was not materialized and is being re-evaluated. Add MATERIALIZED keyword.

    SQL Server: SET STATISTICS IO ON

    SET STATISTICS IO ON;
    SET STATISTICS TIME ON;
    
    -- Your query here
    
    SET STATISTICS IO OFF;
    SET STATISTICS TIME OFF;
    

    Look for the logical reads number for each table. If orders shows up multiple times in the IO stats with significant reads, a subexpression involving it is being evaluated multiple times.

    In the visual execution plan (available in SSMS), look for Spool operators — these indicate the optimizer has chosen to cache an intermediate result. A Table Spool or Index Spool is SQL Server's version of CSE. If you don't see one where you expect it, the optimizer isn't caching.

    BigQuery: Job Execution Details

    BigQuery doesn't have EXPLAIN in the traditional sense, but you can use the Query Execution Details in the BigQuery console after running a query. Look at:

    • Bytes processed — if this is higher than you expect given a single scan of your tables, you have repeated computation
    • The execution graph shows each stage; if the same logical computation appears as separate stages feeding into the DAG, you have duplication

    Optimizer Hints: Precision Instruments, Not Magic

    Optimizer hints should be your last resort after understanding why the optimizer is making the wrong call, not your first response to a slow query. That said, there are legitimate scenarios where hints are the right tool.

    PostgreSQL: The `enable_*` GUC Parameters

    PostgreSQL doesn't have inline hints the way Oracle or SQL Server do, but you can use session-level settings to guide planning:

    -- Force the planner to prefer hash joins over nested loops
    -- (useful when you know a join will produce many rows)
    SET enable_nestloop = off;
    SET enable_hashjoin = on;
    
    WITH customer_ltv AS MATERIALIZED ( ... )
    SELECT ...;
    
    -- Reset after your query
    RESET enable_nestloop;
    RESET enable_hashjoin;
    

    For CSE specifically, the MATERIALIZED keyword in CTE definitions is your primary control surface.

    SQL Server: OPTION(RECOMPILE) and Query Store Hints

    -- Force a fresh plan compilation (useful when parameter sniffing
    -- causes the optimizer to use a plan based on unrepresentative parameters)
    SELECT *
    FROM customers c
    JOIN #tmp_customer_ltv t ON c.customer_id = t.customer_id
    WHERE t.lifetime_value > @threshold
    OPTION (RECOMPILE);
    
    -- SQL Server 2022+: Query Store hints allow you to attach hints
    -- to a query without modifying its text
    EXEC sys.sp_query_store_set_hints
        @query_id = 1234,
        @query_hints = N'OPTION(RECOMPILE, MAXDOP 4)';
    

    The OPTION(USE HINT('DISABLE_OPTIMIZED_PLAN_FORCING')) hint can force the optimizer to consider alternative plans when it's stuck in a suboptimal cached plan.

    Oracle: NO_MERGE and MATERIALIZE Hints

    Oracle has explicit inline hints for controlling CTE and view behavior:

    WITH customer_ltv AS (
        SELECT /*+ MATERIALIZE */
            customer_id,
            SUM(order_total) AS lifetime_value
        FROM orders
        GROUP BY customer_id
    )
    SELECT c.customer_id, ltv.lifetime_value
    FROM customers c
    JOIN customer_ltv ltv ON c.customer_id = ltv.customer_id;
    

    The /*+ MATERIALIZE */ hint inside the CTE definition tells Oracle's optimizer to treat this as a global temporary table result. The complementary hint /*+ INLINE */ forces expansion instead.

    For views and derived tables in Oracle, NO_MERGE prevents the optimizer from merging a view/subquery into the parent query:

    SELECT c.customer_id, v.lifetime_value
    FROM customers c
    JOIN (
        SELECT /*+ NO_MERGE */
            customer_id,
            SUM(order_total) AS lifetime_value
        FROM orders
        GROUP BY customer_id
    ) v ON c.customer_id = v.customer_id;
    

    Without NO_MERGE, Oracle might decide to collapse the derived table into the outer query and apply a different join strategy that re-evaluates the aggregation multiple times.

    Warning about hints: Hints create maintenance debt. If your data distribution changes, your schema evolves, or you upgrade your database version, hints that once improved performance may now hurt it. Document every hint with a comment explaining why it was added and what you observed without it. Include the execution plan statistics at the time of adding the hint.


    Advanced Pattern: Cascading CTEs for Multi-Level CSE

    Real-world analytical queries often have multiple levels of repeated subexpressions — not just one. CTEs can chain, with each CTE building on the previous ones. This is the SQL equivalent of naming intermediate variables in a program.

    WITH
    -- Level 1: Raw aggregates
    order_metrics AS MATERIALIZED (
        SELECT
            o.customer_id,
            o.product_id,
            p.category,
            o.order_date,
            o.order_total,
            SUM(o.order_total) OVER (
                PARTITION BY o.customer_id
                ORDER BY o.order_date
                ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
            ) AS running_customer_ltv,
            AVG(o.order_total) OVER (
                PARTITION BY p.category
                ORDER BY o.order_date
                ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
            ) AS rolling_30d_category_avg
        FROM orders o
        JOIN products p ON o.product_id = p.product_id
    ),
    
    -- Level 2: Customer-level summaries (built from Level 1)
    customer_summary AS MATERIALIZED (
        SELECT
            customer_id,
            MAX(running_customer_ltv)       AS lifetime_value,
            MAX(order_date)                 AS last_order_date,
            AVG(order_total)                AS avg_order_value,
            COUNT(*)                        AS total_orders,
            -- How often did this customer beat the category average?
            SUM(CASE WHEN order_total > rolling_30d_category_avg THEN 1 ELSE 0 END)
                AS orders_above_category_avg
        FROM order_metrics
        GROUP BY customer_id
    ),
    
    -- Level 3: Percentile thresholds (built from Level 2)
    thresholds AS MATERIALIZED (
        SELECT
            PERCENTILE_CONT(0.9)  WITHIN GROUP (ORDER BY lifetime_value)    AS p90_ltv,
            PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY lifetime_value)    AS p75_ltv,
            PERCENTILE_CONT(0.9)  WITHIN GROUP (ORDER BY avg_order_value)   AS p90_aov,
            AVG(lifetime_value)                                              AS mean_ltv,
            STDDEV(lifetime_value)                                           AS stddev_ltv
        FROM customer_summary
    ),
    
    -- Level 4: Scored customers (built from Levels 2 and 3)
    customer_scores AS MATERIALIZED (
        SELECT
            cs.customer_id,
            cs.lifetime_value,
            cs.last_order_date,
            cs.avg_order_value,
            cs.orders_above_category_avg,
            -- Normalized LTV score (z-score style)
            (cs.lifetime_value - t.mean_ltv) / NULLIF(t.stddev_ltv, 0) AS ltv_zscore,
            CASE
                WHEN cs.lifetime_value >= t.p90_ltv AND cs.avg_order_value >= t.p90_aov
                    THEN 'Elite'
                WHEN cs.lifetime_value >= t.p90_ltv
                    THEN 'High Value'
                WHEN cs.lifetime_value >= t.p75_ltv
                    THEN 'Mid-High Value'
                ELSE 'Standard'
            END AS customer_tier
        FROM customer_summary cs
        CROSS JOIN thresholds t
    )
    
    -- Final output: join back to customer table for metadata
    SELECT
        c.customer_id,
        c.email,
        c.signup_date,
        c.sales_rep_id,
        cs.lifetime_value,
        cs.customer_tier,
        cs.ltv_zscore,
        cs.last_order_date,
        cs.orders_above_category_avg,
        CURRENT_DATE - cs.last_order_date AS days_since_last_order
    FROM customers c
    JOIN customer_scores cs ON c.customer_id = cs.customer_id
    ORDER BY cs.lifetime_value DESC;
    

    This query scans orders and products exactly once in the order_metrics CTE. Every subsequent CTE reads from the materialized result of the previous one. The total data touched is minimized.

    Note the CROSS JOIN thresholds t — thresholds is a single-row result (scalar-like), so the cross join is an efficient way to apply those scalar values across every row in customer_summary without a subquery per row.


    When CSE Makes Things Worse: Anti-Patterns

    CSE isn't always the right move. Here are situations where "factoring" a subexpression actually hurts performance.

    Anti-Pattern 1: Materializing Large Results You Then Filter Heavily

    -- BAD: Materializes ALL orders before filtering
    WITH all_orders AS MATERIALIZED (
        SELECT * FROM orders
        JOIN order_line_items oli ON orders.order_id = oli.order_id
        JOIN products p ON oli.product_id = p.product_id
    )
    SELECT * FROM all_orders WHERE customer_id = 12345 AND order_date > '2024-01-01';
    
    -- BETTER: Let the planner push the filter down
    WITH all_orders AS NOT MATERIALIZED (
        SELECT * FROM orders
        JOIN order_line_items oli ON orders.order_id = oli.order_id
        JOIN products p ON oli.product_id = p.product_id
    )
    SELECT * FROM all_orders WHERE customer_id = 12345 AND order_date > '2024-01-01';
    -- OR: just write the query directly without the CTE
    

    If a CTE result is 10 million rows but you're filtering it down to 500, materializing the 10 million rows wastes memory and time. Let the predicate push down.

    Anti-Pattern 2: CTEs That Disable Useful Optimizer Transformations

    -- This CTE prevents the optimizer from using an index skip scan
    -- that would have been available if the subquery were inlined
    WITH customer_categories AS MATERIALIZED (
        SELECT DISTINCT primary_category FROM customers
    )
    SELECT * FROM products p
    WHERE p.category IN (SELECT primary_category FROM customer_categories);
    
    -- Without the CTE, the optimizer might choose a semi-join strategy
    -- that avoids materializing the customer_categories set entirely
    SELECT * FROM products p
    WHERE p.category IN (SELECT DISTINCT primary_category FROM customers);
    

    Sometimes the optimizer knows better. Force materialization only when you have evidence (from execution plans) that the default behavior is wrong.

    Anti-Pattern 3: Over-Factoring for Readability at the Cost of Performance

    CTEs are excellent for readability, but don't let the desire for clean code override execution efficiency. A 12-level CTE chain where each level is referenced exactly once is just bureaucracy — the planner has to wade through 12 logical transformations, each of which may degrade cardinality estimates.

    The cardinality estimation problem: Every time the optimizer estimates how many rows a CTE will return, it introduces potential error. In a long CTE chain, these estimation errors compound. If order_metrics returns an estimated 5 million rows but the planner thinks it'll return 500,000, every downstream CTE will make join and memory decisions based on the wrong number. Sometimes one well-structured query with a few key derived tables produces better estimates than 10 CTEs with compounding errors.


    Hands-On Exercise

    You have the following schema:

    • events(event_id, user_id, event_type, event_date, session_id, revenue)
    • users(user_id, email, signup_date, country, plan_type)
    • sessions(session_id, user_id, start_time, end_time, channel)

    Task: Write a query that returns, for each user:

    1. Their total revenue (lifetime)
    2. Their revenue in the last 90 days
    3. Whether they're above the 80th percentile for lifetime revenue in their country
    4. Their average session duration in minutes
    5. Their most common acquisition channel (the channel appearing most in their sessions)

    Constraints:

    • Each subexpression should be computed exactly once
    • The query must handle ties in "most common channel" (pick alphabetically first)
    • The query must not use correlated subqueries in the SELECT or WHERE clause
    • You must use EXPLAIN ANALYZE (or your engine's equivalent) to verify execution plan behavior

    Starter structure:

    WITH
    user_revenue AS MATERIALIZED ( ... ),
    country_thresholds AS MATERIALIZED ( ... ),
    session_stats AS MATERIALIZED ( ... ),
    channel_ranked AS MATERIALIZED ( ... ),
    primary_channel AS MATERIALIZED ( ... )
    SELECT ...
    FROM users u
    JOIN ...
    

    Fill in each CTE, verify the final query touches each base table exactly once, and check the execution plan to confirm materialization.


    Common Mistakes & Troubleshooting

    "My CTE isn't being materialized even with MATERIALIZED keyword"

    On PostgreSQL versions before 12, the MATERIALIZED keyword doesn't exist — CTEs were always materialized. If you're on PostgreSQL 12+ and your CTE isn't materializing despite the keyword, check the PostgreSQL version (the keyword requires 12+) and ensure there's no syntax error causing fallback to default behavior.

    "My temp table approach is slower than the original query"

    This usually happens when:

    1. You forgot to add indexes to the temp table before joining to it
    2. The temp table is on disk rather than in memory because work_mem (PostgreSQL) or tempdb allocation (SQL Server) is too small
    3. The optimizer's statistics for the temp table are stale — run ANALYZE (PostgreSQL) or UPDATE STATISTICS (SQL Server) after populating the temp table

    "The execution plan shows my CTE being scanned multiple times"

    In PostgreSQL, this appears as multiple CTE Scan nodes with the same CTE name. This can happen when a CTE is used inside a nested loop where the outer side produces many rows — the CTE is read once per outer row. Add MATERIALIZED and the CTE result is stored; or restructure the query so the CTE is joined at the top level rather than used as a correlated subquery.

    "SQL Server keeps expanding my CTEs no matter what"

    SQL Server doesn't support MATERIALIZED keyword in CTE definitions. Your options are:

    1. Use #temp tables (most reliable)
    2. Use SELECT INTO #tmp FROM (...) CTE pattern
    3. Use a view with an actual index (indexed view)
    4. In some cases, adding OPTION (MAXRECURSION 0) or other query-level hints can influence plan shapes, but this is engine-version-dependent

    "My query is faster after refactoring but produces different results"

    This is a correctness issue, not a performance issue. Common causes:

    • The original correlated subquery had implicit scope that your CTE didn't capture (e.g., the outer query's column was filtering the subquery's results via a correlated reference that you dropped when factoring)
    • NULLs behave differently in JOIN vs. correlated subquery contexts (EXISTS vs. IN vs. JOIN handle NULLs differently)
    • Your DISTINCT in a derived table changes row counts in unexpected ways

    Always validate refactored queries against the original results on a sample dataset before deploying.


    Summary & Next Steps

    Query rewriting for common subexpression elimination is one of the highest-leverage optimization skills you can develop. The core principles are:

    • Identify before factoring: Annotate your query to find every repeated computation before writing a single line of the refactored version
    • Choose the right tool: CTEs with MATERIALIZED for within-query CSE on PostgreSQL; temp tables for SQL Server and multi-statement scenarios; materialized views for cross-session repeated access
    • Verify with execution plans: The goal is to see each expensive base-table computation appear exactly once in the execution plan. Don't assume — look
    • Understand your engine's defaults: CTE materialization behavior varies wildly between PostgreSQL 11/12+, SQL Server, MySQL, and BigQuery. Test your assumptions
    • Use hints as documentation, not magic: Every hint should come with a comment explaining what problem it solves, with evidence

    The most important mindset shift is treating SQL like you'd treat application code: name your intermediate computations, define them once, and reference the named result everywhere it's needed. CTEs are not just a readability tool — when used correctly, they're a precision instrument for controlling what the database actually computes.

    Next steps in your learning path:

    1. Adaptive Query Execution in Spark SQL and BigQuery: Understand how distributed engines apply CSE at the DAG level and how shuffle optimization relates to subexpression materialization
    2. Incremental View Maintenance: Learn how databases like dbt, Materialize, and Redshift implement view freshness strategies — this is CSE taken to the schema-level extreme
    3. Query Plan Stability and Parameter Sniffing: Deep dive into how parameter-dependent plans interact with your CSE refactoring in SQL Server and PostgreSQL
    4. Statistics and Cardinality Estimation: Understanding where the optimizer's row count estimates come from, and how to fix them when they're wrong — because bad estimates undermine even the best CSE refactoring

    The query that took 45 seconds and got you into this lesson? With the techniques here, you should be able to get it under 5 seconds on the same hardware. Go verify it with EXPLAIN (ANALYZE, BUFFERS).

    Learning Path: Advanced SQL Queries

    Previous

    Partitioning Strategies in SQL: Using Table Partitioning and Partition Pruning to Accelerate Queries on Large Datasets

    Related Articles

    SQL⚡ Practitioner

    Partitioning Strategies in SQL: Using Table Partitioning and Partition Pruning to Accelerate Queries on Large Datasets

    24 min
    SQL🌱 Foundation

    Datetime Arithmetic and Interval Calculations in SQL: Converting, Truncating, and Computing Durations Across Database Platforms

    14 min
    SQL🔥 Expert

    Analytical Query Patterns with FILTER, WITHIN GROUP, and Ordered-Set Aggregates for Advanced Statistical Reporting

    27 min

    On this page

    • Introduction
    • Prerequisites
    • The Problem: What Repeated Subexpressions Actually Cost
    • Understanding How SQL Engines Handle Repeated Logic
    • The Logical vs. Physical Execution Gap
    • How Different Engines Handle CTEs
    • Technique 1: CTEs with Explicit Materialization
    • Step 1: Identify All Repeated Computations
    • Step 2: Factor into CTEs
    • The `NOT MATERIALIZED` Escape Hatch
    • Technique 2: Derived Tables and Inline Views
    • When Derived Tables Are the Right Choice
    • Technique 3: Temporary Tables for Cross-Query and Session-Level CSE
    • Technique 4: Indexed Views and Materialized Views
    • Reading Execution Plans to Verify Your Refactoring
    • PostgreSQL: EXPLAIN (ANALYZE, BUFFERS)
    • SQL Server: SET STATISTICS IO ON
    • BigQuery: Job Execution Details
    • Optimizer Hints: Precision Instruments, Not Magic
    • PostgreSQL: The `enable_*` GUC Parameters
    • SQL Server: OPTION(RECOMPILE) and Query Store Hints
    • Oracle: NO_MERGE and MATERIALIZE Hints
    • Advanced Pattern: Cascading CTEs for Multi-Level CSE
    • When CSE Makes Things Worse: Anti-Patterns
    • Anti-Pattern 1: Materializing Large Results You Then Filter Heavily
    • Anti-Pattern 2: CTEs That Disable Useful Optimizer Transformations
    • Anti-Pattern 3: Over-Factoring for Readability at the Cost of Performance
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • "My CTE isn't being materialized even with MATERIALIZED keyword"
    • "My temp table approach is slower than the original query"
    • "The execution plan shows my CTE being scanned multiple times"
    • "SQL Server keeps expanding my CTEs no matter what"
    • "My query is faster after refactoring but produces different results"
    • Summary & Next Steps