Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
SQL

Writing SQL FROM Scratch: Structuring Multi-Step Analytical Queries with Derived Tables and Inline Views

Most complex analytical questions can't be answered in a single flat query — they require computing something, and then computing something on top of that. This lesson teaches you how to structure multi-step SQL using derived tables and inline views, with realistic examples, optimizer internals, and a complete hands-on exercise.

🔥 Expert26 min readSep 24, 2026Updated Sep 24, 2026
Writing SQL FROM Scratch: Structuring Multi-Step Analytical Queries with Derived Tables and Inline Views
On this page
  • Prerequisites
  • What Are Derived Tables and Inline Views?
  • Recognizing When You Need a Derived Table
  • Pattern 1: Filtering on an Aggregate
  • Pattern 2: Joining an Aggregation to Row-Level Data
  • Pattern 3: Reusing a Computed Value Without Repeating Logic
  • Pattern 4: Multi-Level Aggregation
  • Anatomy of a Well-Written Derived Table
  • The Alias Is Not Optional
  • Column References Use the Alias
  • All Columns You Need Must Be Projected
Building a Real Multi-Step Analytical Query
  • Derived Tables vs. CTEs: When to Use Which
  • Performance Characteristics and Optimizer Behavior
  • How Optimizers Handle Derived Tables
  • When Materialization Does Happen
  • Index Usage Through Derived Tables
  • Filtering Strategies Across Query Layers
  • Predicate Pushdown: Do It Explicitly, Not Implicitly
  • When to Filter in the Outer Query
  • Composing Derived Tables with Multiple JOINs
  • Derived Tables in UPDATE and DELETE Statements
  • Advanced Pattern: Derived Tables for Row Deduplication
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Forgetting the Alias
  • Mistake 2: Column Name Ambiguity
  • Mistake 3: Selecting More Columns Than You Group By (in the Inner Query)
  • Mistake 4: Counting NULLs as Zeros
  • Mistake 5: Assuming Derived Table Rows Are Distinct
  • Mistake 6: Putting ORDER BY Inside a Derived Table
  • Architectural Trade-Offs: Derived Tables vs. Views vs. Materialized Views
  • Summary & Next Steps
  • Writing SQL FROM Scratch: Structuring Multi-Step Analytical Queries with Derived Tables and Inline Views

    There's a particular kind of analytical problem that breaks most SQL beginners — and even trips up intermediate practitioners — where the answer you need isn't sitting in a single table waiting to be selected. Instead, it lives in the relationship between aggregations, or it requires filtering on the result of a calculation that doesn't exist yet when the WHERE clause runs, or it demands that you compute something in one pass and then use that result as input to a second computation. The instinct is to write everything in one giant query. The result is usually a mess that neither you nor your colleagues can debug.

    Derived tables and inline views are the foundational tool for breaking this kind of problem apart without creating temporary tables, without writing stored procedures, and without resorting to application-layer glue. They let you compose complex analytical logic the same way a good engineer composes functions: write a small, correct piece, give it a name, and build the next layer on top of it. By the end of this lesson, you'll be able to look at a gnarly analytical requirement and immediately see the layered structure hiding inside it — and translate that structure into clean, correct, readable SQL.

    What you'll learn:

    • What derived tables and inline views actually are, and how the query engine sees them
    • How to identify when a problem requires a multi-step approach versus a flat query
    • How to construct and alias derived tables correctly across different SQL dialects
    • How to nest derived tables, compose them with JOINs, and filter across layers
    • Performance characteristics, optimizer behavior, and when to prefer CTEs or materialized views instead
    • Common structural mistakes that produce wrong answers silently

    Prerequisites

    You should be comfortable with SQL basics including SELECT, FROM, and WHERE, and you should understand how aggregate functions with GROUP BY and HAVING work before diving in. Some familiarity with subqueries in filtering contexts will help, but it isn't strictly required.


    What Are Derived Tables and Inline Views?

    Both terms refer to the same structural concept: a complete SELECT statement written inside parentheses within the FROM clause of an outer query, given an alias, and treated by the query engine as if it were a physical table. The name "derived table" is common in SQL Server and MySQL documentation. "Inline view" is the Oracle idiom. In PostgreSQL, you'll often see the more generic term "subquery in the FROM clause." They all mean the same thing.

    Here's the simplest possible example:

    SELECT
        dept_summary.department_name,
        dept_summary.total_salary
    FROM (
        SELECT
            department_name,
            SUM(salary) AS total_salary
        FROM employees
        GROUP BY department_name
    ) AS dept_summary
    WHERE dept_summary.total_salary > 500000;
    

    The inner query runs first, producing a virtual result set with two columns: department_name and total_salary. The outer query can then reference that virtual result set by its alias (dept_summary) and filter against total_salary — a column that doesn't exist in the raw employees table and couldn't be used directly in a WHERE clause on that table.

    Key insight

    The reason you can't just write WHERE SUM(salary) > 500000 directly is that WHERE filters run before aggregation. The derived table forces aggregation to complete in the inner query first, producing a real column that the outer WHERE can legitimately filter on. This is the most common reason you'll reach for a derived table.

    The query engine materializes (or in many cases, logically inlines) the derived table result before processing the outer query. This matters for understanding both correctness and performance, which we'll get into in depth.


    Recognizing When You Need a Derived Table

    The decision to use a derived table isn't aesthetic — it's structural. There are specific patterns that demand it.

    Pattern 1: Filtering on an Aggregate

    As shown above, you cannot use an aggregate expression in a WHERE clause because WHERE evaluates before GROUP BY. You can use HAVING to filter aggregates, but HAVING only applies within the same GROUP BY level. If you need to filter a different query based on a grouped result, a derived table is your tool.

    Pattern 2: Joining an Aggregation to Row-Level Data

    This is extremely common in reporting. You have customer-level transaction data and you want each transaction row annotated with what percentage of the customer's total spend that transaction represents.

    SELECT
        t.transaction_id,
        t.customer_id,
        t.amount,
        t.amount / customer_totals.total_spend AS pct_of_total
    FROM transactions t
    JOIN (
        SELECT
            customer_id,
            SUM(amount) AS total_spend
        FROM transactions
        GROUP BY customer_id
    ) AS customer_totals
        ON t.customer_id = customer_totals.customer_id;
    

    The derived table computes one row per customer. The JOIN then brings that aggregated value back to the transaction-level grain. You simply cannot do this in a flat query without a derived table, CTE, or window function.

    Pattern 3: Reusing a Computed Value Without Repeating Logic

    SQL doesn't let you reference a SELECT-list alias in the same SELECT clause (or in WHERE). So if you compute something complex — say, a bucketed revenue tier using a CASE expression — you can't filter or GROUP BY that expression by name in the same query. You have to either repeat the whole CASE expression or wrap the query in a derived table.

    SELECT
        revenue_tier,
        COUNT(*) AS customer_count,
        AVG(lifetime_value) AS avg_ltv
    FROM (
        SELECT
            customer_id,
            lifetime_value,
            CASE
                WHEN lifetime_value < 500 THEN 'Low'
                WHEN lifetime_value < 2000 THEN 'Mid'
                ELSE 'High'
            END AS revenue_tier
        FROM customers
    ) AS tiered_customers
    GROUP BY revenue_tier;
    

    Without the derived table, you'd need to repeat the entire CASE expression in the GROUP BY clause. With it, you write the logic once and build cleanly on top.

    Tip

    The pattern of "compute something in the inner query, then GROUP BY or filter on it in the outer query" is one of the highest-value moves in analytical SQL. It makes the intent of each query layer explicit.

    Pattern 4: Multi-Level Aggregation

    You want to aggregate, and then aggregate again at a higher level. For example: first compute total revenue per order, then average those order totals per customer.

    SELECT
        customer_id,
        AVG(order_total) AS avg_order_value,
        COUNT(*) AS order_count
    FROM (
        SELECT
            order_id,
            customer_id,
            SUM(line_amount) AS order_total
        FROM order_line_items
        GROUP BY order_id, customer_id
    ) AS order_totals
    GROUP BY customer_id;
    

    If you tried to collapse this into a single GROUP BY, you'd need grouping sets or a different approach. The derived table makes the two-stage logic unambiguous.


    Anatomy of a Well-Written Derived Table

    Let's slow down and look at the mechanical requirements, because small mistakes here cause cryptic errors.

    The Alias Is Not Optional

    In most databases — and strictly required in SQL Server, MySQL, and PostgreSQL — every derived table must have an alias. Even if you never reference it explicitly in the outer query, the alias tells the parser that this is a named result set.

    -- This fails in PostgreSQL, MySQL, and SQL Server:
    SELECT * FROM (
        SELECT product_id, SUM(quantity) AS units_sold
        FROM order_items
        GROUP BY product_id
    );
    -- ERROR: subquery in FROM must have an alias
    
    -- This is correct:
    SELECT * FROM (
        SELECT product_id, SUM(quantity) AS units_sold
        FROM order_items
        GROUP BY product_id
    ) AS product_sales;
    

    Oracle is more permissive here — it allows unnamed inline views in some contexts — but it's still good practice to always name them.

    Column References Use the Alias

    In the outer query, reference columns through the derived table alias, exactly as you would with any table or view:

    SELECT
        ps.product_id,
        ps.units_sold
    FROM (
        SELECT product_id, SUM(quantity) AS units_sold
        FROM order_items
        GROUP BY product_id
    ) AS ps
    WHERE ps.units_sold > 100;
    

    All Columns You Need Must Be Projected

    The outer query can only see columns that the inner query explicitly selects. If you forget to include a column in the inner SELECT, you can't use it in the outer query. This sounds obvious but causes real bugs when you refactor queries.

    -- Broken: we need department_id in the outer query but forgot to select it
    SELECT
        dept_summary.department_name,
        dept_summary.total_salary,
        dept_summary.department_id  -- ERROR: doesn't exist in inner query
    FROM (
        SELECT
            department_name,          -- forgot department_id
            SUM(salary) AS total_salary
        FROM employees
        GROUP BY department_name
    ) AS dept_summary;
    

    Building a Real Multi-Step Analytical Query

    Let's work through a complete, realistic scenario. We have an e-commerce database with these tables:

    • orders — one row per order (order_id, customer_id, order_date, status)
    • order_items — one row per line item (item_id, order_id, product_id, quantity, unit_price)
    • customers — one row per customer (customer_id, signup_date, country, channel)
    • products — one row per product (product_id, category, cost_price)

    Business question: For each customer acquisition channel, what is the average order value, the average number of items per order, and the percentage of customers who have placed more than one order?

    This is a four-step problem:

    1. Compute revenue per order
    2. Compute per-customer order counts and average order value
    3. Compute per-channel aggregates
    4. Compute the "repeat purchase rate" (requires knowing how many customers exist per channel total and how many have order_count > 1)

    Let's build it layer by layer.

    Step 1: Order-level revenue

    -- Inner query: revenue per order
    SELECT
        oi.order_id,
        SUM(oi.quantity * oi.unit_price) AS order_revenue,
        SUM(oi.quantity) AS total_items
    FROM order_items oi
    GROUP BY oi.order_id
    

    Step 2: Customer-level summary (joining orders)

    -- Second layer: per-customer metrics
    SELECT
        o.customer_id,
        COUNT(DISTINCT o.order_id) AS order_count,
        AVG(ord_revenue.order_revenue) AS avg_order_value,
        SUM(ord_revenue.total_items) / COUNT(DISTINCT o.order_id) AS avg_items_per_order
    FROM orders o
    JOIN (
        SELECT
            order_id,
            SUM(quantity * unit_price) AS order_revenue,
            SUM(quantity) AS total_items
        FROM order_items
        GROUP BY order_id
    ) AS ord_revenue
        ON o.order_id = ord_revenue.order_id
    WHERE o.status = 'completed'
    GROUP BY o.customer_id
    

    Step 3: Pull in channel, flag repeat buyers, then aggregate by channel

    Now we bring together the customer-level summary with the customers table to get the channel, and we use a CASE expression to flag repeat buyers. Then the outermost query aggregates at the channel level:

    SELECT
        channel_summary.channel,
        AVG(channel_summary.avg_order_value) AS channel_avg_order_value,
        AVG(channel_summary.avg_items_per_order) AS channel_avg_items,
        SUM(channel_summary.is_repeat_buyer) * 1.0
            / COUNT(*) AS repeat_purchase_rate
    FROM (
        SELECT
            c.channel,
            cust_metrics.avg_order_value,
            cust_metrics.avg_items_per_order,
            CASE WHEN cust_metrics.order_count > 1 THEN 1 ELSE 0 END AS is_repeat_buyer
        FROM customers c
        JOIN (
            SELECT
                o.customer_id,
                COUNT(DISTINCT o.order_id) AS order_count,
                AVG(ord_revenue.order_revenue) AS avg_order_value,
                SUM(ord_revenue.total_items) * 1.0
                    / COUNT(DISTINCT o.order_id) AS avg_items_per_order
            FROM orders o
            JOIN (
                SELECT
                    order_id,
                    SUM(quantity * unit_price) AS order_revenue,
                    SUM(quantity) AS total_items
                FROM order_items
                GROUP BY order_id
            ) AS ord_revenue
                ON o.order_id = ord_revenue.order_id
            WHERE o.status = 'completed'
            GROUP BY o.customer_id
        ) AS cust_metrics
            ON c.customer_id = cust_metrics.customer_id
    ) AS channel_summary
    GROUP BY channel_summary.channel
    ORDER BY channel_avg_order_value DESC;
    

    This is a three-level nested derived table structure. The innermost query handles the item-level aggregation. The middle layer handles customer-level metrics. The outer wrapper attaches channel information and flags repeat buyers. The outermost query aggregates by channel.

    Warning

    Deep nesting like this is correct and often necessary, but it becomes difficult to debug when something goes wrong. A good practice is to develop each inner layer as a standalone query, verify its output looks right, and then embed it. Never write three levels at once from scratch — you'll introduce bugs you can't localize.


    Derived Tables vs. CTEs: When to Use Which

    Since CTEs (WITH clauses) do essentially the same thing as derived tables, you need a principled view of when to use each. If you want a thorough comparison with advanced patterns, Advanced Subqueries and CTEs: Mastering Complex SQL Query Architecture covers the full spectrum. Here's the architectural decision framework:

    Use derived tables when:

    • The subquery is used in exactly one place in the outer query
    • You want to minimize query structure overhead for simple two-layer queries
    • You're writing SQL that will be embedded in a system that has trouble parsing WITH clauses (some older BI tools)
    • You're intentionally keeping the structure self-contained in the FROM clause for readability

    Use CTEs when:

    • You need to reference the same subquery result more than once
    • You have more than two levels of nesting and readability is suffering
    • You want to document each logical step with a meaningful name at the top of the query
    • You're building recursive queries (derived tables cannot be recursive)

    The query we just built is a perfect example of where a CTE refactor dramatically improves maintainability:

    WITH order_revenue AS (
        SELECT
            order_id,
            SUM(quantity * unit_price) AS order_revenue,
            SUM(quantity) AS total_items
        FROM order_items
        GROUP BY order_id
    ),
    customer_metrics AS (
        SELECT
            o.customer_id,
            COUNT(DISTINCT o.order_id) AS order_count,
            AVG(r.order_revenue) AS avg_order_value,
            SUM(r.total_items) * 1.0
                / COUNT(DISTINCT o.order_id) AS avg_items_per_order
        FROM orders o
        JOIN order_revenue r ON o.order_id = r.order_id
        WHERE o.status = 'completed'
        GROUP BY o.customer_id
    ),
    channel_flags AS (
        SELECT
            c.channel,
            m.avg_order_value,
            m.avg_items_per_order,
            CASE WHEN m.order_count > 1 THEN 1 ELSE 0 END AS is_repeat_buyer
        FROM customers c
        JOIN customer_metrics m ON c.customer_id = m.customer_id
    )
    SELECT
        channel,
        AVG(avg_order_value) AS channel_avg_order_value,
        AVG(avg_items_per_order) AS channel_avg_items,
        SUM(is_repeat_buyer) * 1.0 / COUNT(*) AS repeat_purchase_rate
    FROM channel_flags
    GROUP BY channel
    ORDER BY channel_avg_order_value DESC;
    

    The logic is identical. The readability is dramatically better. The CTE version is also much easier to debug — you can run each CTE independently by temporarily adding SELECT * FROM order_revenue as the main query.

    Key insight

    Derived tables and CTEs are not competing tools — they're the same conceptual move expressed with different syntax. Learn derived tables because they're foundational to understanding how SQL composes queries. Prefer CTEs in production code where maintainability matters.


    Performance Characteristics and Optimizer Behavior

    This is where things get genuinely nuanced, and where many tutorials fail you by oversimplifying.

    How Optimizers Handle Derived Tables

    Modern query optimizers (PostgreSQL, SQL Server, Oracle, MySQL 8+) do not blindly materialize derived tables as temporary tables and then process the outer query. They perform query unnesting — they try to merge the derived table back into the outer query and optimize the whole thing as a single logical unit.

    This means that in many cases, the following two queries produce identical execution plans:

    -- Derived table version
    SELECT d.customer_id, d.total_orders
    FROM (
        SELECT customer_id, COUNT(*) AS total_orders
        FROM orders
        GROUP BY customer_id
    ) AS d
    WHERE d.total_orders > 5;
    
    -- Flat HAVING version
    SELECT customer_id, COUNT(*) AS total_orders
    FROM orders
    GROUP BY customer_id
    HAVING COUNT(*) > 5;
    

    The optimizer sees through the derived table and pushes the filter down. So you're not paying a performance penalty for structuring your query in layers — at least not for simple cases.

    Tip

    Use EXPLAIN (PostgreSQL/MySQL) or EXPLAIN PLAN / execution plan tools (SQL Server, Oracle) to verify that derived tables in your specific query are being unnested rather than materialized. The output will tell you whether you have a "SubqueryScan" node (materialization) or whether the inner logic has been merged into the outer plan.

    When Materialization Does Happen

    Optimizers cannot always unnest. Derived tables get materialized (actually computed and stored in memory or temp space) in these situations:

    Aggregations with non-deterministic functions: If your derived table includes RANDOM(), NEWID(), or similar, the optimizer must materialize to avoid re-evaluation.

    Self-joins on the derived table: If you join a derived table to itself, some optimizers will materialize to avoid computing it twice — though CTEs with the MATERIALIZED hint (PostgreSQL 12+) give you explicit control here.

    Derived tables containing LIMIT/OFFSET: Optimizers generally cannot push predicates through a LIMIT, so they materialize first.

    Complex predicate pushdown failures: When the optimizer's predicate-pushdown analysis can't determine that it's safe to move a filter inside the derived table, it falls back to materializing the full result and filtering outside.

    Materialization isn't always bad — for expensive inner queries that are joined to multiple outer queries, materialization can be a net win. But for large tables, materializing an intermediate result that could have been filtered down first is the most common performance pitfall with derived tables.

    Index Usage Through Derived Tables

    A common concern: can the optimizer use indexes on base tables when those tables are wrapped in a derived table? Yes, as long as the query is unnested. The filter predicates and join conditions get pushed down to the base table scans, where indexes can be used normally.

    If the derived table is materialized as a temp result, that result won't have indexes on it. Subsequent joins and filters on the materialized result are full scans of the temp data. For large intermediate results, this can be catastrophic. This is one reason to understand how indexes work and when to create them — it affects how you structure these query layers.


    Filtering Strategies Across Query Layers

    One of the most important architectural decisions in multi-layer queries is where to apply filters. The general principle is to filter as early as possible — push filters into the innermost derived table to reduce the rows that subsequent layers process.

    Predicate Pushdown: Do It Explicitly, Not Implicitly

    Don't rely on the optimizer to push your filters down. Write them where they logically belong:

    -- Worse: filter in outer query (forces aggregation over all data first)
    SELECT *
    FROM (
        SELECT customer_id, SUM(amount) AS total_spend
        FROM transactions
        GROUP BY customer_id
    ) AS t
    WHERE t.customer_id IN (
        SELECT customer_id FROM customers WHERE country = 'US'
    );
    
    -- Better: filter before aggregation
    SELECT customer_id, SUM(amount) AS total_spend
    FROM transactions
    WHERE customer_id IN (
        SELECT customer_id FROM customers WHERE country = 'US'
    )
    GROUP BY customer_id;
    

    The first version aggregates all customers, then discards non-US ones. The second filters to US customers first. Depending on what fraction of your data is US customers, this difference can be enormous.

    In a derived table context:

    -- Push the filter inside the derived table
    SELECT
        cs.customer_id,
        cs.total_spend,
        c.country
    FROM (
        SELECT customer_id, SUM(amount) AS total_spend
        FROM transactions
        WHERE transaction_date >= '2024-01-01'  -- filter here, not outside
        GROUP BY customer_id
    ) AS cs
    JOIN customers c ON cs.customer_id = c.customer_id;
    

    Warning

    It's tempting to put all your filtering in the outer query because it "feels cleaner." In practice, outer filters applied to derived tables that have already aggregated large amounts of data can cause dramatic performance problems. Profile before you assume the optimizer handles it.

    When to Filter in the Outer Query

    Sometimes filtering belongs in the outer query because the filter condition references a column from the outer query or from a JOIN that happens at the outer level. This is correct and expected. The key is intentionality — filter at the level where the data you're filtering against first becomes available.


    Composing Derived Tables with Multiple JOINs

    A single outer query can JOIN multiple derived tables together, just like joining multiple real tables. This is extremely powerful for combining different aggregation grains.

    Consider a scenario where we want to analyze products: for each product, we want its total units sold, its average selling price, and its average margin. The margin requires the cost from products, the selling data from order_items.

    SELECT
        p.product_id,
        p.category,
        sales.units_sold,
        sales.avg_selling_price,
        sales.avg_selling_price - p.cost_price AS avg_margin_per_unit,
        (sales.avg_selling_price - p.cost_price) / sales.avg_selling_price AS margin_pct
    FROM products p
    JOIN (
        SELECT
            product_id,
            SUM(quantity) AS units_sold,
            SUM(quantity * unit_price) / SUM(quantity) AS avg_selling_price
        FROM order_items
        GROUP BY product_id
    ) AS sales
        ON p.product_id = sales.product_id
    WHERE sales.units_sold > 0
    ORDER BY margin_pct DESC;
    

    Now extend this: what if we also want to show, for each product, how its performance compares to the category average? We need a second derived table that computes category-level averages:

    SELECT
        p.product_id,
        p.category,
        sales.units_sold,
        sales.avg_selling_price,
        sales.avg_selling_price - p.cost_price AS avg_margin_per_unit,
        cat_avg.category_avg_price,
        sales.avg_selling_price / cat_avg.category_avg_price AS price_vs_category
    FROM products p
    JOIN (
        SELECT
            product_id,
            SUM(quantity) AS units_sold,
            SUM(quantity * unit_price) / SUM(quantity) AS avg_selling_price
        FROM order_items
        GROUP BY product_id
    ) AS sales
        ON p.product_id = sales.product_id
    JOIN (
        SELECT
            pr.category,
            AVG(oi_agg.avg_selling_price) AS category_avg_price
        FROM products pr
        JOIN (
            SELECT
                product_id,
                SUM(quantity * unit_price) / SUM(quantity) AS avg_selling_price
            FROM order_items
            GROUP BY product_id
        ) AS oi_agg
            ON pr.product_id = oi_agg.product_id
        GROUP BY pr.category
    ) AS cat_avg
        ON p.category = cat_avg.category
    WHERE sales.units_sold > 0
    ORDER BY price_vs_category DESC;
    

    Notice that the order_items aggregation is written twice here — once inside sales and once inside cat_avg. This is an argument for using a CTE: define the order item aggregation once at the top, reference it in both places. Common Table Expressions provide exactly this kind of reusability without repeating inner queries.

    Key insight

    When you find yourself writing the same derived table in multiple places within one query, that's a clear signal to refactor to CTEs. Repetition in SQL queries isn't just an aesthetic problem — it means the optimizer may execute the same scan twice.


    Derived Tables in UPDATE and DELETE Statements

    Derived tables aren't limited to SELECT queries. In SQL Server, you can use them in UPDATE and DELETE statements as well:

    -- Update customers to 'VIP' tier based on an aggregated threshold
    UPDATE customers
    SET tier = 'VIP'
    WHERE customer_id IN (
        SELECT customer_id
        FROM (
            SELECT customer_id, SUM(amount) AS total_spend
            FROM orders
            GROUP BY customer_id
        ) AS spend_summary
        WHERE total_spend > 10000
    );
    

    PostgreSQL handles this with a FROM clause update:

    UPDATE customers c
    SET tier = 'VIP'
    FROM (
        SELECT customer_id, SUM(amount) AS total_spend
        FROM orders
        GROUP BY customer_id
        HAVING SUM(amount) > 10000
    ) AS spend_summary
    WHERE c.customer_id = spend_summary.customer_id;
    

    This pattern becomes important once you understand data modification operations deeply — the ability to drive updates with aggregated logic without temporary tables is a significant capability.


    Advanced Pattern: Derived Tables for Row Deduplication

    One very practical use of derived tables is deduplicating rows based on a ranking expression. This requires computing a rank in the inner query and filtering on it in the outer query, since you can't use window functions in WHERE directly.

    -- Keep only the most recent order per customer
    SELECT
        deduped.customer_id,
        deduped.order_id,
        deduped.order_date,
        deduped.total_amount
    FROM (
        SELECT
            o.customer_id,
            o.order_id,
            o.order_date,
            SUM(oi.quantity * oi.unit_price) AS total_amount,
            ROW_NUMBER() OVER (
                PARTITION BY o.customer_id
                ORDER BY o.order_date DESC
            ) AS rn
        FROM orders o
        JOIN order_items oi ON o.order_id = oi.order_id
        GROUP BY o.customer_id, o.order_id, o.order_date
    ) AS deduped
    WHERE deduped.rn = 1;
    

    The inner query computes ROW_NUMBER() alongside the aggregation. The outer query filters to rn = 1. This pattern comes up constantly — for deduplication, for "top N per group" problems, for latest-record lookups. If you want to go deeper on window functions like ROW_NUMBER and RANK, they compose naturally with derived tables.


    Hands-On Exercise

    Work through this multi-step problem on a database of your choice. If you don't have one handy, you can create the schema with the CREATE statements below.

    Scenario: You're an analyst at a SaaS company. You have:

    • accounts (account_id, plan_type, signup_date, region)
    • events (event_id, account_id, event_type, event_date)
    • subscriptions (subscription_id, account_id, mrr, start_date, end_date)

    The question: For each plan type and region combination, calculate:

    1. The average number of events per account in the first 30 days after signup
    2. The average MRR across all active subscriptions
    3. The percentage of accounts that had at least one event in their first 30 days

    Your approach should be:

    1. Write a derived table that computes, per account, the count of events within 30 days of signup. Verify it looks correct.

    2. Write a derived table that computes, per account, their current active MRR (subscriptions where end_date is NULL or in the future). Verify it.

    3. Write an outer query that joins both derived tables to the accounts table, uses a CASE expression to flag accounts with at least one early event, and then aggregates at the plan_type + region level.

    Challenge extension: After completing the above, refactor the entire query to use CTEs instead of nested derived tables. Note which version you find more readable and why.

    Setup SQL:

    CREATE TABLE accounts (
        account_id SERIAL PRIMARY KEY,
        plan_type VARCHAR(20),
        signup_date DATE,
        region VARCHAR(50)
    );
    
    CREATE TABLE events (
        event_id SERIAL PRIMARY KEY,
        account_id INT REFERENCES accounts(account_id),
        event_type VARCHAR(50),
        event_date DATE
    );
    
    CREATE TABLE subscriptions (
        subscription_id SERIAL PRIMARY KEY,
        account_id INT REFERENCES accounts(account_id),
        mrr DECIMAL(10,2),
        start_date DATE,
        end_date DATE
    );
    

    Tip

    Don't try to write the whole query at once. Build the innermost derived tables first, run them standalone, check their row counts and column values, then start composing. The ability to validate each layer independently is the most underrated benefit of this approach.


    Common Mistakes & Troubleshooting

    Mistake 1: Forgetting the Alias

    Already covered, but worth repeating because it's the most common error. Every derived table needs an alias in SQL Server, MySQL, and PostgreSQL. If you see ERROR: subquery in FROM must have an alias, this is why.

    Mistake 2: Column Name Ambiguity

    When you join derived tables to real tables and they share column names, the outer query becomes ambiguous. Always qualify your columns:

    -- Ambiguous and may error or produce wrong results
    SELECT customer_id, total_spend
    FROM customers c
    JOIN (
        SELECT customer_id, SUM(amount) AS total_spend
        FROM transactions GROUP BY customer_id
    ) AS t ON c.customer_id = t.customer_id;
    
    -- Clear and correct
    SELECT c.customer_id, t.total_spend
    FROM customers c
    JOIN (
        SELECT customer_id, SUM(amount) AS total_spend
        FROM transactions GROUP BY customer_id
    ) AS t ON c.customer_id = t.customer_id;
    

    Mistake 3: Selecting More Columns Than You Group By (in the Inner Query)

    Inside a derived table, standard GROUP BY rules still apply. If you SELECT order_date but don't GROUP BY it, you'll get an error in strict databases or non-deterministic results in permissive ones (MySQL's ONLY_FULL_GROUP_BY mode catches this).

    Mistake 4: Counting NULLs as Zeros

    When a derived table uses a JOIN and some accounts/customers have no matching rows in the joined table, a direct JOIN produces no row at all for those accounts — not a row with a zero. To get zeros, use a LEFT JOIN and COALESCE:

    SELECT
        a.account_id,
        COALESCE(e.event_count, 0) AS event_count  -- not just e.event_count
    FROM accounts a
    LEFT JOIN (
        SELECT account_id, COUNT(*) AS event_count
        FROM events
        GROUP BY account_id
    ) AS e ON a.account_id = e.account_id;
    

    Forgetting this produces silently wrong results — accounts with no events simply vanish from your analysis instead of showing up with a zero. Understanding NULL handling throughout SQL is critical when building these multi-layer queries.

    Mistake 5: Assuming Derived Table Rows Are Distinct

    A derived table is just a SELECT result. If the inner query doesn't deduplicate, the outer query won't either. If you JOIN on a column that isn't unique in your derived table, you'll get a fan-out (row multiplication) that inflates all your aggregates. Always know the grain of your derived table.

    -- Dangerous if product_id is not unique in the derived table:
    SELECT
        p.product_id,
        sales.revenue
    FROM products p
    JOIN (
        SELECT product_id, SUM(amount) AS revenue
        FROM sales_events  -- what if this table has non-additive duplicates?
        GROUP BY product_id
    ) AS sales ON p.product_id = sales.product_id;
    

    Verify the inner query produces one row per join key before embedding it.

    Mistake 6: Putting ORDER BY Inside a Derived Table

    Many databases either ignore or prohibit ORDER BY inside a derived table without a LIMIT/TOP. The ordering is meaningless — the outer query has no guarantee it will receive rows in any particular order. Only apply ORDER BY in the outermost query. This is also related to broader filtering and sorting concepts covered in advanced SQL filtering and sorting.


    Architectural Trade-Offs: Derived Tables vs. Views vs. Materialized Views

    If you find yourself using the same derived table logic in multiple queries, that's a signal to evaluate alternatives:

    Database Views let you store the SELECT definition in the database so it can be referenced by name. They don't store data — the view's query runs every time you query it. This gives you the same performance characteristics as a derived table, with the reusability of a named object. Great for stable logic that many queries share.

    Materialized Views actually store the query result as a physical table and refresh on a schedule or on demand. When your inner derived table is an expensive aggregation over a large table that doesn't change frequently, a materialized view can replace it — the heavy computation runs once at refresh time, and all queries hit the cached result. This is worth understanding deeply if you're doing high-performance analytics.

    Temporary Tables are the manual version of materialized intermediate results. In stored procedures or multi-step ETL queries, you sometimes want to INSERT the result of a derived table into a temp table explicitly, add an index to it, and then JOIN against the indexed temp table. This is occasionally the right move for extremely large intermediate results where the optimizer doesn't make good choices automatically.

    The decision tree:

    • One-off query → derived table or CTE
    • Reused across multiple queries → view
    • Expensive and infrequently changing → materialized view
    • Complex ETL with large intermediate results → temp table with indexes

    Summary & Next Steps

    Derived tables are how you impose structure on complexity. The core idea — write a complete query, give it a name, build on top of it — is the same idea that makes all good software: decompose problems into logical layers, validate each layer, compose upward. SQL's architecture makes this explicit by forcing you to express that layering in the query itself.

    The skills you've developed here connect directly to everything in analytical SQL:

    • You now understand why CTEs and derived tables are equivalent, and when to prefer each
    • You can identify the structural patterns that demand multi-step queries — aggregate-then-filter, multi-grain joins, computed column reuse
    • You understand how query optimizers handle derived tables, when they materialize, and what that means for performance
    • You know the common silent-failure modes: NULL fan-outs, grain mismatches, column ambiguity

    Where to go next:

    If you want to push further into multi-step query patterns, Advanced Subqueries and CTEs: Mastering Complex SQL Query Architecture takes these ideas into correlated subqueries, EXISTS/NOT EXISTS, and recursive structures. For the analytical side — cohort analysis, funnels, retention metrics — SQL for Data Analysis: Cohort Analysis, Funnels, and Retention shows you how derived tables compose into full analytical frameworks. And if performance is your bottleneck, Database Performance Tuning: Advanced Indexing Strategies and Query Rewriting for Production Systems covers the execution plan analysis and rewrite techniques you'll need when these queries hit production scale.

    The best analytical SQL writers aren't faster at typing — they're better at seeing the layers.

    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    SQL Fundamentals

    Previous

    Combining Aggregates with Conditional Logic: GROUP BY, HAVING, and CASE WHEN in Practice

    Related Insights

    SQLPractitioner

    Combining Aggregates with Conditional Logic: GROUP BY, HAVING, and CASE WHEN in Practice

    21 min
    SQLFoundation

    Understanding SQL Subqueries: Filtering and Looking Up Data with Nested SELECT Statements

    15 min
    SQLExpert

    Multi-Table Reporting with JOIN and GROUP BY: Aggregating Across Relationships in a Single Query

    27 min

    On this page

    • Prerequisites
    • What Are Derived Tables and Inline Views?
    • Recognizing When You Need a Derived Table
    • Pattern 1: Filtering on an Aggregate
    • Pattern 2: Joining an Aggregation to Row-Level Data
    • Pattern 3: Reusing a Computed Value Without Repeating Logic
    • Pattern 4: Multi-Level Aggregation
    • Anatomy of a Well-Written Derived Table
    • The Alias Is Not Optional
    • Column References Use the Alias
    • All Columns You Need Must Be Projected
    • Building a Real Multi-Step Analytical Query
    • Derived Tables vs. CTEs: When to Use Which
    • Performance Characteristics and Optimizer Behavior
    • How Optimizers Handle Derived Tables
    • When Materialization Does Happen
    • Index Usage Through Derived Tables
    • Filtering Strategies Across Query Layers
    • Predicate Pushdown: Do It Explicitly, Not Implicitly
    • When to Filter in the Outer Query
    • Composing Derived Tables with Multiple JOINs
    • Derived Tables in UPDATE and DELETE Statements
    • Advanced Pattern: Derived Tables for Row Deduplication
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Forgetting the Alias
    • Mistake 2: Column Name Ambiguity
    • Mistake 3: Selecting More Columns Than You Group By (in the Inner Query)
    • Mistake 4: Counting NULLs as Zeros
    • Mistake 5: Assuming Derived Table Rows Are Distinct
    • Mistake 6: Putting ORDER BY Inside a Derived Table
    • Architectural Trade-Offs: Derived Tables vs. Views vs. Materialized Views
    • Summary & Next Steps