📘 SERIES · CHAPTER 78🗄 SQL · ADVANCED

Advanced SQL — Window Functions, CTEs, Query Optimisation & Real-World Patterns

Basic SQL gets you a seat at the table. Advanced SQL — window functions, multi-step CTEs, and query optimisation — is what separates an analyst who pulls pre-built reports from one who builds the complex metrics that no one else on the team can write. This chapter covers every advanced concept that appears in real production environments and senior analyst interviews.

⏱ 28 min read📅 October 2026✍ EVIKA ACADEMY, Noida

Window Function Anatomy — Understanding OVER, PARTITION BY, and ORDER BY

A window function performs a calculation across a set of rows related to the current row — without collapsing the result set the way GROUP BY does. Every window function uses the OVER clause. Inside OVER, PARTITION BY divides rows into groups and ORDER BY defines the order within each group.

FUNCTION_NAME(expression)
OVER (
PARTITION BY column -- reset the window for each group
ORDER BY column ASC|DESC -- defines row order within the window
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- optional frame
)
Key rule: window functions are evaluated after WHERE and GROUP BY but before ORDER BY and LIMIT. You cannot filter on a window function result in the same query's WHERE clause — use a subquery or CTE to wrap it.

Ranking Functions — ROW_NUMBER, RANK, DENSE_RANK

These three functions all assign a rank to rows within a partition. The difference matters when there are ties:

FunctionTie behaviourScores: 100, 100, 80Use when
ROW_NUMBER()Always unique — arbitrary for ties1, 2, 3Deduplication — pick exactly one row
RANK()Ties share rank; gap after1, 1, 3Olympic-style ranking (gold, gold, bronze)
DENSE_RANK()Ties share rank; no gap1, 1, 2Top-N filtering when ties should count once
-- Rank customers by revenue within each region (DENSE_RANK for top-N)
SELECT
    customer_id,
    region,
    total_revenue,
    DENSE_RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) AS revenue_rank
FROM customer_summary;

-- Filter top 3 per region — must wrap in a subquery or CTE
WITH ranked AS (
    SELECT
        customer_id, region, total_revenue,
        DENSE_RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) AS rnk
    FROM customer_summary
)
SELECT * FROM ranked WHERE rnk <= 3;

-- Deduplication: keep the most recent row per order_id (ROW_NUMBER)
WITH deduped AS (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) AS rn
    FROM raw_orders
)
SELECT * FROM deduped WHERE rn = 1;

LAG and LEAD — Period-over-Period Comparisons

LAG accesses the value from a previous row in the ordered window. LEAD accesses the value from a following row. Both are the SQL equivalent of "compare this value to the row above/below."

-- Month-over-Month revenue growth
SELECT
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month)                           AS prev_month_rev,
    revenue - LAG(revenue) OVER (ORDER BY month)                 AS mom_change,
    ROUND(
        (revenue - LAG(revenue) OVER (ORDER BY month))
        / LAG(revenue) OVER (ORDER BY month) * 100, 1
    )                                                            AS mom_pct_change
FROM monthly_revenue;

-- LAG with PARTITION BY: MoM within each product category
SELECT
    category,
    month,
    revenue,
    LAG(revenue, 1, 0) OVER (PARTITION BY category ORDER BY month) AS prev_month,
    -- third argument = default when no prior row exists (avoids NULL)
    revenue - LAG(revenue, 1, 0) OVER (PARTITION BY category ORDER BY month) AS change
FROM category_monthly_revenue;

-- LEAD: days until the next order per customer
SELECT
    customer_id,
    order_date,
    LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_date,
    LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)
        - order_date                                              AS days_between_orders
FROM orders;

Running Totals, Moving Averages & Window Frames

Aggregate functions (SUM, AVG, COUNT, MIN, MAX) become window functions when combined with OVER. The ROWS BETWEEN / RANGE BETWEEN clause defines which rows to include in the calculation — called the window frame.

-- Running total (cumulative sum)
SELECT
    order_date,
    daily_revenue,
    SUM(daily_revenue) OVER (ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM daily_sales;

-- 7-day moving average
SELECT
    order_date,
    daily_revenue,
    AVG(daily_revenue) OVER (ORDER BY order_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)   AS moving_avg_7d,
    -- also: 30-day
    AVG(daily_revenue) OVER (ORDER BY order_date
        ROWS BETWEEN 29 PRECEDING AND CURRENT ROW)  AS moving_avg_30d
FROM daily_sales;

-- Running total partitioned by region
SELECT
    region, order_date, daily_revenue,
    SUM(daily_revenue) OVER (
        PARTITION BY region
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS region_running_total
FROM regional_daily_sales;

-- Percentage of total so far (running %)
SELECT
    order_date,
    daily_revenue,
    SUM(daily_revenue) OVER (ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
        / SUM(daily_revenue) OVER () * 100           AS running_pct
FROM daily_sales;
Common frame shortcuts: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW = from start to current row (running total). ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING = all rows (same as no frame — grand total). OVER () with no partition and no order = entire table as one window.

Multi-Step CTEs — Building Complex Queries in Layers

A WITH clause can define multiple CTEs in sequence, each building on the previous. This is the most readable way to write complex analytics queries — each step is named and can be read like a recipe.

-- Multi-step CTE: customer lifetime value analysis
WITH
-- Step 1: aggregate orders per customer
customer_orders AS (
    SELECT
        customer_id,
        COUNT(*)                        AS total_orders,
        SUM(revenue)                    AS total_revenue,
        MIN(order_date)                 AS first_order,
        MAX(order_date)                 AS last_order
    FROM orders
    GROUP BY customer_id
),
-- Step 2: calculate derived metrics
customer_metrics AS (
    SELECT
        customer_id,
        total_orders,
        total_revenue,
        first_order,
        last_order,
        total_revenue / total_orders                    AS avg_order_value,
        DATEDIFF(last_order, first_order)               AS days_as_customer,
        DATEDIFF(CURRENT_DATE, last_order)              AS days_since_last_order
    FROM customer_orders
),
-- Step 3: segment customers
customer_segments AS (
    SELECT *,
        CASE
            WHEN total_revenue >= 100000 AND days_since_last_order <= 90 THEN 'Champion'
            WHEN total_revenue >= 50000  AND days_since_last_order <= 180 THEN 'Loyal'
            WHEN days_since_last_order > 365                              THEN 'At Risk'
            ELSE 'Regular'
        END AS segment
    FROM customer_metrics
)
-- Final output: summary by segment
SELECT
    segment,
    COUNT(*)            AS customers,
    ROUND(AVG(total_revenue), 0) AS avg_ltv,
    ROUND(AVG(avg_order_value), 0) AS avg_order_value,
    ROUND(AVG(days_since_last_order), 0) AS avg_days_inactive
FROM customer_segments
GROUP BY segment
ORDER BY avg_ltv DESC;

Recursive CTEs — Hierarchies and Date Series

A recursive CTE references itself, allowing queries over hierarchical data (org charts, bill-of-materials, category trees) and generation of sequences (date series, numbered ranges).

-- Generate a continuous date series (no gaps, even on days with no sales)
-- Useful for charts that must show every day even if revenue = 0
WITH RECURSIVE date_series AS (
    SELECT DATE('2026-01-01') AS dt          -- anchor (starting row)
    UNION ALL
    SELECT dt + INTERVAL 1 DAY               -- recursive step
    FROM date_series
    WHERE dt < DATE('2026-12-31')            -- termination condition
)
SELECT
    ds.dt                                    AS order_date,
    COALESCE(s.revenue, 0)                   AS revenue   -- 0 for missing days
FROM date_series ds
LEFT JOIN daily_sales s ON s.order_date = ds.dt
ORDER BY ds.dt;

-- Traverse an org hierarchy: find all reports under a manager
WITH RECURSIVE org_tree AS (
    -- Anchor: start with the target manager
    SELECT employee_id, name, manager_id, 1 AS depth
    FROM employees
    WHERE employee_id = 101              -- root manager

    UNION ALL

    -- Recursive step: join employees to their manager in the tree
    SELECT e.employee_id, e.name, e.manager_id, ot.depth + 1
    FROM employees e
    INNER JOIN org_tree ot ON e.manager_id = ot.employee_id
)
SELECT employee_id, name, depth
FROM org_tree
ORDER BY depth, name;
Note: Recursive CTEs require RECURSIVE keyword in PostgreSQL, MySQL 8+, and SQLite 3.8+. SQL Server uses WITH ... AS (...) without the RECURSIVE keyword but supports the same UNION ALL pattern.

Query Optimisation — Reading EXPLAIN and Applying Fixes

When a query is slow, EXPLAIN (or EXPLAIN ANALYZE in PostgreSQL) shows the query execution plan — how the database engine will retrieve the data. Reading this plan tells you exactly where time is being spent.

-- PostgreSQL: EXPLAIN ANALYZE shows actual run time + row estimates
EXPLAIN ANALYZE
SELECT customer_id, SUM(revenue)
FROM orders
WHERE order_date >= '2026-01-01'
GROUP BY customer_id;

-- Key terms to look for in the output:
-- Seq Scan         → reading every row in the table (bad on large tables)
-- Index Scan       → using an index (good — targeted lookup)
-- Index Only Scan  → even better — never touches the table itself
-- Hash Join        → joining using a hash table (usually efficient for large sets)
-- Nested Loop      → joining row-by-row (fast for small sets, slow for large ones)
-- Sort             → explicit sort step (check if an index could remove it)
-- cost=X..Y        → estimated cost; actual time=X..Y shows real execution time
-- rows=N           → estimated row count; big difference from actual = stale statistics

-- Fix 1: create an index on frequently filtered columns
CREATE INDEX idx_orders_date ON orders(order_date);
CREATE INDEX idx_orders_customer ON orders(customer_id);

-- Fix 2: composite index for queries that filter + group
CREATE INDEX idx_orders_date_customer ON orders(order_date, customer_id);

-- Fix 3: avoid functions on indexed columns in WHERE — they bypass the index
-- Bad (function prevents index use):
WHERE YEAR(order_date) = 2026
-- Good (range scan uses the index):
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'
6 optimisation rules for analysts
✅ Filter early
Apply WHERE conditions before JOINs when possible. Joining smaller filtered tables is faster than filtering after a large join.
✅ Avoid SELECT *
Select only the columns you need. SELECT * forces the engine to fetch all columns and prevents index-only scans.
✅ Use EXISTS instead of IN for large subqueries
WHERE EXISTS (SELECT 1 FROM ...) stops as soon as one match is found; IN fetches the full subquery result first.
✅ Avoid correlated subqueries in SELECT
A subquery in the SELECT clause runs once per row. Replace with a JOIN or window function.
✅ Aggregate before joining
Pre-aggregate large tables in a CTE before joining — reduces the number of rows the join processes.
✅ Update statistics regularly
Run ANALYZE (PostgreSQL) or UPDATE STATISTICS (SQL Server) after large data loads so the query planner has accurate row counts.

8 SQL Patterns Every Analyst Uses in Production

These are the query patterns that appear repeatedly in real analytics work — bookmark this section.

📌 Cohort Retention

Group users by their first-order month and track what % return in each subsequent month.

WITH cohorts AS (
    SELECT customer_id, DATE_TRUNC('month', MIN(order_date)) AS cohort_month
    FROM orders GROUP BY customer_id
),
activity AS (
    SELECT o.customer_id, c.cohort_month,
        DATE_TRUNC('month', o.order_date) AS activity_month,
        DATEDIFF('month', c.cohort_month, DATE_TRUNC('month', o.order_date)) AS period
    FROM orders o JOIN cohorts c USING (customer_id)
)
SELECT cohort_month, period, COUNT(DISTINCT customer_id) AS active_customers
FROM activity GROUP BY 1, 2 ORDER BY 1, 2;
📌 Pivot (CASE SUM)

Turn rows into columns — e.g. one column per month — without a PIVOT keyword.

SELECT
    product_id,
    SUM(CASE WHEN MONTH(order_date) = 1 THEN revenue ELSE 0 END) AS jan,
    SUM(CASE WHEN MONTH(order_date) = 2 THEN revenue ELSE 0 END) AS feb,
    SUM(CASE WHEN MONTH(order_date) = 3 THEN revenue ELSE 0 END) AS mar
FROM orders
WHERE YEAR(order_date) = 2026
GROUP BY product_id;
📌 First/Last Value per Group

Get the first and last event per user — e.g. first purchase category, most recent product.

WITH ranked AS (
    SELECT customer_id, product_category, order_date,
        FIRST_VALUE(product_category) OVER
            (PARTITION BY customer_id ORDER BY order_date ASC
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS first_category,
        LAST_VALUE(product_category) OVER
            (PARTITION BY customer_id ORDER BY order_date ASC
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_category
    FROM orders
)
SELECT DISTINCT customer_id, first_category, last_category FROM ranked;
📌 Gap and Island Detection

Find consecutive sequences (e.g. consecutive active days) or gaps in a series.

-- Assign each row to a "group" based on consecutive date gaps
SELECT
    customer_id, activity_date,
    activity_date - ROW_NUMBER() OVER
        (PARTITION BY customer_id ORDER BY activity_date)::int AS grp
FROM user_activity;
-- Rows with the same grp value are consecutive. GROUP BY grp to find streaks.

Master Advanced SQL at EVIKA ACADEMY

Our SQL course in Noida Sector 51 covers window functions, CTEs, recursive queries, optimisation, and real-world analytics patterns — with hands-on practice on actual datasets. Online & offline batches available.

📱 Book Free Demo Class →

Frequently Asked Questions

What is the difference between ROW_NUMBER, RANK, and DENSE_RANK in SQL?

ROW_NUMBER always assigns a unique number, with an arbitrary choice for ties. RANK gives tied rows the same rank and leaves a gap after. DENSE_RANK gives tied rows the same rank with no gap. Use ROW_NUMBER for deduplication, DENSE_RANK for top-N filtering where ties should share a rank.

What is a CTE in SQL and when should you use it?

A CTE is a named temporary result set defined with WITH. Use it when you need to reference a sub-result more than once, when multi-step logic would create deeply nested subqueries, when you need a recursive query for hierarchies or date series, or when you want to write and test each analytical step independently.

How do you write a running total in SQL?

SUM(revenue) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Add PARTITION BY before ORDER BY to reset the running total for each group.

How do you optimise a slow SQL query?

Start with EXPLAIN ANALYZE. Look for Seq Scan on large tables (add an index), Nested Loop on large datasets, and stale row estimates. Common fixes: index filtered and joined columns, avoid functions on indexed columns in WHERE, select only needed columns, filter early before joins, pre-aggregate in CTEs.

Where can I learn advanced SQL for data analytics in Noida?

EVIKA ACADEMY in Noida Sector 51 teaches window functions, CTEs, recursive queries, optimisation, and production patterns as part of its data analytics course. Online and offline batches. WhatsApp +91-8081035456.

🎓 Free Demo Class — Online & Offline · Noida Sector 51