SQL Keywords

SQL Keywords

WITH (Common Table Expressions - CTEs)

Feb 23, 2026

·

5

min read

Category: Subqueries / Query Structure

Platform Support:

✅ Snowflake | ✅ BigQuery | ✅ Databricks

Description

WITH clause (Common Table Expression or CTE) creates temporary named result sets that exist only during query execution. CTEs improve query readability, enable recursive queries, and allow you to reference the same subquery multiple times. Think of them as temporary views that exist for a single query.

Syntax

-- Single CTE
WITH cte_name AS (
    SELECT columns
    FROM table
    WHERE condition
)
SELECT *
FROM cte_name;

-- Multiple CTEs
WITH 
cte1 AS (
    SELECT ...
),
cte2 AS (
    SELECT ...
    FROM cte1  -- Can reference previous CTEs
)
SELECT *
FROM cte1
JOIN cte2 ON

-- Single CTE
WITH cte_name AS (
    SELECT columns
    FROM table
    WHERE condition
)
SELECT *
FROM cte_name;

-- Multiple CTEs
WITH 
cte1 AS (
    SELECT ...
),
cte2 AS (
    SELECT ...
    FROM cte1  -- Can reference previous CTEs
)
SELECT *
FROM cte1
JOIN cte2 ON

-- Single CTE
WITH cte_name AS (
    SELECT columns
    FROM table
    WHERE condition
)
SELECT *
FROM cte_name;

-- Multiple CTEs
WITH 
cte1 AS (
    SELECT ...
),
cte2 AS (
    SELECT ...
    FROM cte1  -- Can reference previous CTEs
)
SELECT *
FROM cte1
JOIN cte2 ON

Platform-Specific Notes

Snowflake:

  • Full support for recursive CTEs

  • CTEs can be materialized for performance

  • Supports QUALIFY with CTEs

BigQuery:

  • Full support for recursive CTEs

  • Optimizes CTE execution automatically

  • Supports WITH in subqueries

Databricks:

  • Full support for recursive CTEs

  • Spark optimization for CTE execution

  • Can cache CTE results

Example 1: Basic CTE for Readability

All Platforms:

-- Without CTE (harder to read)
SELECT 
    c.customer_id,
    c.name,
    order_stats.total_orders,
    order_stats.total_spent
FROM customers c
JOIN (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
) order_stats ON c.customer_id = order_stats.customer_id;

-- With CTE (more readable)
WITH order_stats AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
)
SELECT 
    c.customer_id,
    c.name,
    os.total_orders,
    os.total_spent
FROM customers c
JOIN order_stats os ON

-- Without CTE (harder to read)
SELECT 
    c.customer_id,
    c.name,
    order_stats.total_orders,
    order_stats.total_spent
FROM customers c
JOIN (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
) order_stats ON c.customer_id = order_stats.customer_id;

-- With CTE (more readable)
WITH order_stats AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
)
SELECT 
    c.customer_id,
    c.name,
    os.total_orders,
    os.total_spent
FROM customers c
JOIN order_stats os ON

-- Without CTE (harder to read)
SELECT 
    c.customer_id,
    c.name,
    order_stats.total_orders,
    order_stats.total_spent
FROM customers c
JOIN (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
) order_stats ON c.customer_id = order_stats.customer_id;

-- With CTE (more readable)
WITH order_stats AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as total_spent
    FROM orders
    WHERE order_date >= '2024-01-01'
    GROUP BY customer_id
)
SELECT 
    c.customer_id,
    c.name,
    os.total_orders,
    os.total_spent
FROM customers c
JOIN order_stats os ON

Sample Data:

customers:

customer_id

name

1

John Smith

2

Maria Garcia

3

Sarah Johnson

orders:

order_id

customer_id

order_date

total

101

1

2024-01-15

250.00

102

2

2024-01-20

180.00

103

1

2024-01-25

320.00

Result:

customer_id

name

total_orders

total_spent

1

John Smith

2

570.00

2

Maria Garcia

1

180.00

Example 2: Multiple CTEs Building on Each Other

All Platforms:

WITH 
-- First CTE: Get monthly sales
monthly_sales AS (
    SELECT 
        DATE_TRUNC('MONTH', order_date) as month,
        SUM(total) as revenue,
        COUNT(*) as order_count
    FROM orders
    GROUP BY DATE_TRUNC('MONTH', order_date)
),
-- Second CTE: Calculate month-over-month change
sales_with_growth AS (
    SELECT 
        month,
        revenue,
        order_count,
        LAG(revenue) OVER (ORDER BY month) as prev_month_revenue,
        revenue - LAG(revenue) OVER (ORDER BY month) as revenue_change,
        (revenue - LAG(revenue) OVER (ORDER BY month)) * 100.0 
            / LAG(revenue) OVER (ORDER BY month) as growth_rate_pct
    FROM monthly_sales
),
-- Third CTE: Categorize performance
performance_rated AS (
    SELECT 
        *,
        CASE 
            WHEN growth_rate_pct > 10 THEN 'Excellent'
            WHEN growth_rate_pct > 0 THEN 'Good'
            WHEN growth_rate_pct > -10 THEN 'Declining'
            ELSE 'Poor'
        END as performance_rating
    FROM sales_with_growth
)
SELECT 
    month,
    revenue,
    order_count,
    COALESCE(revenue_change, 0) as revenue_change,
    COALESCE(ROUND(growth_rate_pct, 2), 0) as growth_rate_pct,
    performance_rating
FROM performance_rated
ORDER BY month

WITH 
-- First CTE: Get monthly sales
monthly_sales AS (
    SELECT 
        DATE_TRUNC('MONTH', order_date) as month,
        SUM(total) as revenue,
        COUNT(*) as order_count
    FROM orders
    GROUP BY DATE_TRUNC('MONTH', order_date)
),
-- Second CTE: Calculate month-over-month change
sales_with_growth AS (
    SELECT 
        month,
        revenue,
        order_count,
        LAG(revenue) OVER (ORDER BY month) as prev_month_revenue,
        revenue - LAG(revenue) OVER (ORDER BY month) as revenue_change,
        (revenue - LAG(revenue) OVER (ORDER BY month)) * 100.0 
            / LAG(revenue) OVER (ORDER BY month) as growth_rate_pct
    FROM monthly_sales
),
-- Third CTE: Categorize performance
performance_rated AS (
    SELECT 
        *,
        CASE 
            WHEN growth_rate_pct > 10 THEN 'Excellent'
            WHEN growth_rate_pct > 0 THEN 'Good'
            WHEN growth_rate_pct > -10 THEN 'Declining'
            ELSE 'Poor'
        END as performance_rating
    FROM sales_with_growth
)
SELECT 
    month,
    revenue,
    order_count,
    COALESCE(revenue_change, 0) as revenue_change,
    COALESCE(ROUND(growth_rate_pct, 2), 0) as growth_rate_pct,
    performance_rating
FROM performance_rated
ORDER BY month

WITH 
-- First CTE: Get monthly sales
monthly_sales AS (
    SELECT 
        DATE_TRUNC('MONTH', order_date) as month,
        SUM(total) as revenue,
        COUNT(*) as order_count
    FROM orders
    GROUP BY DATE_TRUNC('MONTH', order_date)
),
-- Second CTE: Calculate month-over-month change
sales_with_growth AS (
    SELECT 
        month,
        revenue,
        order_count,
        LAG(revenue) OVER (ORDER BY month) as prev_month_revenue,
        revenue - LAG(revenue) OVER (ORDER BY month) as revenue_change,
        (revenue - LAG(revenue) OVER (ORDER BY month)) * 100.0 
            / LAG(revenue) OVER (ORDER BY month) as growth_rate_pct
    FROM monthly_sales
),
-- Third CTE: Categorize performance
performance_rated AS (
    SELECT 
        *,
        CASE 
            WHEN growth_rate_pct > 10 THEN 'Excellent'
            WHEN growth_rate_pct > 0 THEN 'Good'
            WHEN growth_rate_pct > -10 THEN 'Declining'
            ELSE 'Poor'
        END as performance_rating
    FROM sales_with_growth
)
SELECT 
    month,
    revenue,
    order_count,
    COALESCE(revenue_change, 0) as revenue_change,
    COALESCE(ROUND(growth_rate_pct, 2), 0) as growth_rate_pct,
    performance_rating
FROM performance_rated
ORDER BY month

Sample Data (orders table):

order_id

order_date

total

101

2024-01-15

250.00

102

2024-01-20

180.00

103

2024-02-10

320.00

104

2024-02-15

280.00

105

2024-03-05

450.00

Result:

month

revenue

order_count

revenue_change

growth_rate_pct

performance_rating

2024-01-01

430.00

2

0.00

0.00

Good

2024-02-01

600.00

2

170.00

39.53

Excellent

2024-03-01

450.00

1

-150.00

-25.00

Poor

Example 3: Recursive CTE - Organizational Hierarchy

All Platforms:

WITH RECURSIVE employee_hierarchy AS (
    -- Anchor: Start with CEO (no manager)
    SELECT 
        employee_id,
        name,
        manager_id,
        title,
        1 as level,
        CAST(name AS VARCHAR(1000)) as hierarchy_path
    FROM employees
    WHERE manager_id IS NULL
    
    UNION ALL
    
    -- Recursive: Get employees reporting to previous level
    SELECT 
        e.employee_id,
        e.name,
        e.manager_id,
        e.title,
        eh.level + 1,
        CAST(eh.hierarchy_path || ' > ' || e.name AS VARCHAR(1000))
    FROM employees e
    INNER JOIN employee_hierarchy eh ON e.manager_id = eh.employee_id
)
SELECT 
    employee_id,
    name,
    title,
    level,
    hierarchy_path
FROM employee_hierarchy
ORDER BY level,

WITH RECURSIVE employee_hierarchy AS (
    -- Anchor: Start with CEO (no manager)
    SELECT 
        employee_id,
        name,
        manager_id,
        title,
        1 as level,
        CAST(name AS VARCHAR(1000)) as hierarchy_path
    FROM employees
    WHERE manager_id IS NULL
    
    UNION ALL
    
    -- Recursive: Get employees reporting to previous level
    SELECT 
        e.employee_id,
        e.name,
        e.manager_id,
        e.title,
        eh.level + 1,
        CAST(eh.hierarchy_path || ' > ' || e.name AS VARCHAR(1000))
    FROM employees e
    INNER JOIN employee_hierarchy eh ON e.manager_id = eh.employee_id
)
SELECT 
    employee_id,
    name,
    title,
    level,
    hierarchy_path
FROM employee_hierarchy
ORDER BY level,

WITH RECURSIVE employee_hierarchy AS (
    -- Anchor: Start with CEO (no manager)
    SELECT 
        employee_id,
        name,
        manager_id,
        title,
        1 as level,
        CAST(name AS VARCHAR(1000)) as hierarchy_path
    FROM employees
    WHERE manager_id IS NULL
    
    UNION ALL
    
    -- Recursive: Get employees reporting to previous level
    SELECT 
        e.employee_id,
        e.name,
        e.manager_id,
        e.title,
        eh.level + 1,
        CAST(eh.hierarchy_path || ' > ' || e.name AS VARCHAR(1000))
    FROM employees e
    INNER JOIN employee_hierarchy eh ON e.manager_id = eh.employee_id
)
SELECT 
    employee_id,
    name,
    title,
    level,
    hierarchy_path
FROM employee_hierarchy
ORDER BY level,

Sample Data (employees table):

employee_id

name

manager_id

title

1

Alice Chen

NULL

CEO

2

Bob Smith

1

VP Sales

3

Carol White

1

VP Engineering

4

David Brown

2

Sales Manager

5

Emma Davis

3

Engineering Lead

6

Frank Wilson

4

Sales Rep

Result:

employee_id

name

title

level

hierarchy_path

1

Alice Chen

CEO

1

Alice Chen

2

Bob Smith

VP Sales

2

Alice Chen > Bob Smith

3

Carol White

VP Engineering

2

Alice Chen > Carol White

4

David Brown

Sales Manager

3

Alice Chen > Bob Smith > David Brown

5

Emma Davis

Engineering Lead

3

Alice Chen > Carol White > Emma Davis

6

Frank Wilson

Sales Rep

4

Alice Chen > Bob Smith > David Brown > Frank Wilson

Example 4: Recursive CTE - Date Series Generation

All Platforms:

WITH RECURSIVE date_series AS (
    -- Anchor: Start date
    SELECT DATE '2024-01-01' as date
    
    UNION ALL
    
    -- Recursive: Add one day until end date
    SELECT DATE_ADD(date, INTERVAL 1 DAY)
    FROM date_series
    WHERE date < DATE '2024-01-31'
)
SELECT 
    date,
    DAYNAME(date) as day_of_week,
    CASE 
        WHEN DAYNAME(date) IN ('Saturday', 'Sunday') THEN 'Weekend'
        ELSE 'Weekday'
    END as day_type
FROM date_series
ORDER BY

WITH RECURSIVE date_series AS (
    -- Anchor: Start date
    SELECT DATE '2024-01-01' as date
    
    UNION ALL
    
    -- Recursive: Add one day until end date
    SELECT DATE_ADD(date, INTERVAL 1 DAY)
    FROM date_series
    WHERE date < DATE '2024-01-31'
)
SELECT 
    date,
    DAYNAME(date) as day_of_week,
    CASE 
        WHEN DAYNAME(date) IN ('Saturday', 'Sunday') THEN 'Weekend'
        ELSE 'Weekday'
    END as day_type
FROM date_series
ORDER BY

WITH RECURSIVE date_series AS (
    -- Anchor: Start date
    SELECT DATE '2024-01-01' as date
    
    UNION ALL
    
    -- Recursive: Add one day until end date
    SELECT DATE_ADD(date, INTERVAL 1 DAY)
    FROM date_series
    WHERE date < DATE '2024-01-31'
)
SELECT 
    date,
    DAYNAME(date) as day_of_week,
    CASE 
        WHEN DAYNAME(date) IN ('Saturday', 'Sunday') THEN 'Weekend'
        ELSE 'Weekday'
    END as day_type
FROM date_series
ORDER BY

Result (first 7 rows):

date

day_of_week

day_type

2024-01-01

Monday

Weekday

2024-01-02

Tuesday

Weekday

2024-01-03

Wednesday

Weekday

2024-01-04

Thursday

Weekday

2024-01-05

Friday

Weekday

2024-01-06

Saturday

Weekend

2024-01-07

Sunday

Weekend

Example 5: Complex Analysis with Multiple CTEs

All Platforms:

WITH 
-- Get customer order summary
customer_orders AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as lifetime_value,
        MIN(order_date) as first_order_date,
        MAX(order_date) as last_order_date
    FROM orders
    GROUP BY customer_id
),
-- Categorize customers
customer_segments AS (
    SELECT 
        co.*,
        CASE 
            WHEN lifetime_value > 1000 THEN 'VIP'
            WHEN lifetime_value > 500 THEN 'Premium'
            ELSE 'Standard'
        END as segment,
        DATEDIFF(CURRENT_DATE, last_order_date) as days_since_last_order
    FROM customer_orders co
),
-- Calculate segment statistics
segment_stats AS (
    SELECT 
        segment,
        COUNT(*) as customer_count,
        AVG(lifetime_value) as avg_lifetime_value,
        AVG(total_orders) as avg_orders_per_customer,
        AVG(days_since_last_order) as avg_days_since_last_order
    FROM customer_segments
    GROUP BY segment
)
SELECT 
    cs.segment,
    cs.customer_count,
    ROUND(cs.avg_lifetime_value, 2) as avg_lifetime_value,
    ROUND(cs.avg_orders_per_customer, 1) as avg_orders,
    ROUND(cs.avg_days_since_last_order, 0) as avg_days_inactive,
    ROUND(cs.customer_count * 100.0 / SUM(cs.customer_count) OVER (), 2) as pct_of_customers
FROM segment_stats cs
ORDER BY avg_lifetime_value DESC

WITH 
-- Get customer order summary
customer_orders AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as lifetime_value,
        MIN(order_date) as first_order_date,
        MAX(order_date) as last_order_date
    FROM orders
    GROUP BY customer_id
),
-- Categorize customers
customer_segments AS (
    SELECT 
        co.*,
        CASE 
            WHEN lifetime_value > 1000 THEN 'VIP'
            WHEN lifetime_value > 500 THEN 'Premium'
            ELSE 'Standard'
        END as segment,
        DATEDIFF(CURRENT_DATE, last_order_date) as days_since_last_order
    FROM customer_orders co
),
-- Calculate segment statistics
segment_stats AS (
    SELECT 
        segment,
        COUNT(*) as customer_count,
        AVG(lifetime_value) as avg_lifetime_value,
        AVG(total_orders) as avg_orders_per_customer,
        AVG(days_since_last_order) as avg_days_since_last_order
    FROM customer_segments
    GROUP BY segment
)
SELECT 
    cs.segment,
    cs.customer_count,
    ROUND(cs.avg_lifetime_value, 2) as avg_lifetime_value,
    ROUND(cs.avg_orders_per_customer, 1) as avg_orders,
    ROUND(cs.avg_days_since_last_order, 0) as avg_days_inactive,
    ROUND(cs.customer_count * 100.0 / SUM(cs.customer_count) OVER (), 2) as pct_of_customers
FROM segment_stats cs
ORDER BY avg_lifetime_value DESC

WITH 
-- Get customer order summary
customer_orders AS (
    SELECT 
        customer_id,
        COUNT(*) as total_orders,
        SUM(total) as lifetime_value,
        MIN(order_date) as first_order_date,
        MAX(order_date) as last_order_date
    FROM orders
    GROUP BY customer_id
),
-- Categorize customers
customer_segments AS (
    SELECT 
        co.*,
        CASE 
            WHEN lifetime_value > 1000 THEN 'VIP'
            WHEN lifetime_value > 500 THEN 'Premium'
            ELSE 'Standard'
        END as segment,
        DATEDIFF(CURRENT_DATE, last_order_date) as days_since_last_order
    FROM customer_orders co
),
-- Calculate segment statistics
segment_stats AS (
    SELECT 
        segment,
        COUNT(*) as customer_count,
        AVG(lifetime_value) as avg_lifetime_value,
        AVG(total_orders) as avg_orders_per_customer,
        AVG(days_since_last_order) as avg_days_since_last_order
    FROM customer_segments
    GROUP BY segment
)
SELECT 
    cs.segment,
    cs.customer_count,
    ROUND(cs.avg_lifetime_value, 2) as avg_lifetime_value,
    ROUND(cs.avg_orders_per_customer, 1) as avg_orders,
    ROUND(cs.avg_days_since_last_order, 0) as avg_days_inactive,
    ROUND(cs.customer_count * 100.0 / SUM(cs.customer_count) OVER (), 2) as pct_of_customers
FROM segment_stats cs
ORDER BY avg_lifetime_value DESC

Sample Result:

segment

customer_count

avg_lifetime_value

avg_orders

avg_days_inactive

pct_of_customers

VIP

45

2150.50

8.2

12

15.00

Premium

120

685.25

4.1

28

40.00

Standard

135

245.80

1.8

45

45.00

CTE vs Subquery vs Temporary Table

Feature

CTE

Subquery

Temp Table

Readability

Excellent

Poor

Good

Reusability in query

Yes

No

Yes

Persists after query

No

No

Yes (session)

Can be recursive

Yes

No

No

Performance

Optimized

Optimized

May be faster

Scope

Single query

Single query

Session

Common Use Cases

  1. Improve readability: Break complex queries into logical steps

  2. Recursive queries: Hierarchies, date series, graph traversal

  3. Multiple references: Use same subquery multiple times

  4. Step-by-step logic: Build analysis incrementally

  5. Testing/debugging: Isolate and test query components

  6. Data pipeline: Chain transformations clearly

Performance Considerations

  • CTEs are typically optimized by query planner

  • May be materialized or inlined depending on usage

  • Recursive CTEs need termination condition

  • For very large intermediate results, consider temp tables

  • Most modern databases optimize CTEs well

Best Practices

  1. Use descriptive names: Make CTE purpose clear

  2. One logical step per CTE: Keep CTEs focused

  3. Order logically: Build from simple to complex

  4. Comment complex logic: Explain business rules

  5. Limit recursion depth: Prevent infinite loops

  6. Test incrementally: Validate each CTE separately

  7. Consider materialization: For reused expensive CTEs

Interested to Learn More?
Try Out the Free 14-Days Trial

More Articles

decorative icon

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

decorative icon

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

decorative icon

Experience Analytics for the AI-Era

Start your 14-day trial today - it's free and no credit card needed

Copyright © 2026 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.

Copyright © 2026 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.

Copyright © 2026 Paradime Labs, Inc.

Made with ❤️ in San Francisco ・ London

*dbt® and dbt Core® are federally registered trademarks of dbt Labs, Inc. in the United States and various jurisdictions around the world. Paradime is not a partner of dbt Labs. All rights therein are reserved to dbt Labs. Paradime is not a product or service of or endorsed by dbt Labs, Inc.