Knowledge / Computer Science and Engineering / Advanced SQL: subqueries, CTEs and window functions

Advanced SQL: subqueries, CTEs and window functions

Computer Science and Engineering (B.E./B.Tech) · Databases · free study notes for exams, placements and interviews

In short

Advanced SQL is everything beyond a plain SELECT-FROM-WHERE-JOIN: queries nested inside queries (subqueries), named query blocks that can call themselves (CTEs), and functions that compute across a set of rows *without collapsing them* (window or analytic functions). Together they answer the questions business data actually asks — rank, running total, month-on-month growth, top-N per group, hierarchy traversal — in one declarative statement instead of a procedural loop. University papers and GATE (CS/DA) test nested-query semantics, correlated evaluation and output-row counting; campus tests (TCS NQT, Infosys, Wipro, Accenture, Cognizant) and product-company SQL rounds (Flipkart, Zomato, Swiggy, PhonePe, Razorpay, Zoho, Freshworks) almost always ask "Nth highest salary", "top 2 per department" and a window-function problem. Every analytics, data-engineering, BI and backend role in India writes this SQL daily against PostgreSQL, MySQL 8, Oracle, SQL Server or a warehouse.

Explained

Subqueries: kinds and where they may sit

Correlated subqueries, EXISTS and the NULL traps

Common table expressions (CTEs)

Window (analytic) functions

Logical processing order and advanced grouping

  1. FROM / JOIN → 2. WHERE → 3. GROUP BY → 4. HAVING → 5. window functions and SELECT list → 6. DISTINCT → 7. ORDER BY → 8. LIMIT / OFFSET / FETCH FIRST n ROWS WITH TIES.
  2. This order explains: why a SELECT alias is unusable in WHERE but usable in ORDER BY; why window functions cannot sit in WHERE; why WHERE filters rows and HAVING filters groups.
  3. GROUPING SETS, ROLLUP, CUBE (SQL:1999 OLAP; PostgreSQL 9.5+, MySQL supports WITH ROLLUP only) produce several grouping levels in one pass — subtotals and grand totals for reports. GROUPING(col) returns 1 when the column was aggregated away, which distinguishes a subtotal NULL from a real NULL.
  4. FILTER (WHERE condition) on an aggregate (PostgreSQL, SQLite) is the clean form of conditional aggregation; SUM(CASE WHEN ... THEN 1 ELSE 0 END) is the portable equivalent and works in every engine.
  5. Set operators UNION [ALL], INTERSECT, EXCEPT (MINUS in Oracle) combine query blocks; UNION deduplicates (usually by sorting or hashing, but the output order is not guaranteed), UNION ALL does not and is cheaper.

Performance and correctness notes

Key points

Formulas and examples

PERCENT_RANK = (RANK − 1) / (N − 1), where N = rows in the partition; range 0 to 1, first row always 0 (defined as 0 when N = 1)
CUME_DIST = (number of rows with value ≤ current value, in the ORDER BY sense) / N; range > 0 to 1, last row always 1
NTILE(b) with N rows: each bucket gets floor(N/b) rows; the first (N mod b) buckets get one extra row
ROLLUP(c1…cn) → n + 1 grouping sets; CUBE(c1…cn) → 2ⁿ grouping sets
Frame size for ROWS BETWEEN p PRECEDING AND f FOLLOWING = p + f + 1 rows (truncated at partition edges)
Share of total = value / SUM(value) OVER (PARTITION BY group) × 100 (%)
Growth % = (value − LAG(value) OVER (ORDER BY period)) / LAG(value) OVER (ORDER BY period) × 100 (%)
Correlated subquery work ≈ (outer rows) × (cost of one inner scan); the equivalent window function is ≈ one sort + one pass

Example 1 — ranking and running totals (monthly salary in ₹). Table emp(emp, dept, salary): A1 Sales 90,000; A2 Sales 75,000; A3 Sales 75,000; A4 Sales 60,000; A5 Tech 1,20,000; A6 Tech 95,000.

SELECT emp, dept, salary,
       ROW_NUMBER()  OVER w AS rn,
       RANK()        OVER w AS rnk,
       DENSE_RANK()  OVER w AS drnk,
       PERCENT_RANK() OVER w AS prnk,
       CUME_DIST()   OVER w AS cdist,
       SUM(salary) OVER w AS run_range,
       SUM(salary) OVER (PARTITION BY dept ORDER BY salary DESC
                         ROWS UNBOUNDED PRECEDING) AS run_rows
FROM emp
WINDOW w AS (PARTITION BY dept ORDER BY salary DESC);

Sales partition, N = 4, total = 90,000 + 75,000 + 75,000 + 60,000 = ₹3,00,000:

empsalaryrnrnkdrnkprnk = (rnk−1)/3cdistrun_rangerun_rows
A190,0001110.0001/4 = 0.2590,00090,000
A275,0002221/3 = 0.3333/4 = 0.752,40,0001,65,000
A375,0003221/3 = 0.3333/4 = 0.752,40,0002,40,000
A460,0004433/3 = 1.0004/4 = 1.003,00,0003,00,000

Note the two running totals differ for the tied rows: the default RANGE frame gives both 75,000 rows ₹2,40,000, ROWS gives ₹1,65,000 and ₹2,40,000. A1's share of the department = 90,000/3,00,000 × 100 = 30%. Tech: total ₹2,15,000, average ₹1,07,500; A5 prnk = 0, cdist = 0.5.

Example 2 — third-highest salary, three ways. Distinct salaries: 1,20,000 > 95,000 > 90,000 > 75,000 > 60,000, so the answer is ₹90,000.

-- (a) correlated subquery: exactly 2 distinct salaries are higher
SELECT DISTINCT salary FROM emp e
WHERE (SELECT COUNT(DISTINCT s.salary) FROM emp s WHERE s.salary > e.salary) = 2;
-- (b) window function (handles ties correctly)
WITH r AS (SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) dr FROM emp)
SELECT DISTINCT salary FROM r WHERE dr = 3;
-- (c) OFFSET-FETCH (wrong if duplicates are not removed first)
SELECT DISTINCT salary FROM emp ORDER BY salary DESC OFFSET 2 ROWS FETCH NEXT 1 ROW ONLY;

Counting greater salaries for ₹90,000: {1,20,000 , 95,000} → count 2 ✔.

Example 3 — LAG and moving average (branch revenue, ₹).

MonthRevenueLAG(revenue)Growth %3-month MA (ROWS 2 PRECEDING)
Apr12,00,000NULL—12,00,000
May13,50,00012,00,000+12.5(12,00,000+13,50,000)/2 = 12,75,000
Jun12,15,00013,50,000−10.037,65,000/3 = 12,55,000
Jul14,58,00012,15,000+20.040,23,000/3 = 13,41,000

Check: (13,50,000 − 12,00,000)/12,00,000 = 0.125; (12,15,000 − 13,50,000)/13,50,000 = −0.10; (14,58,000 − 12,15,000)/12,15,000 = 0.20.

Example 4 — recursive CTE on a reporting hierarchy. staff(id, name, mgr_id): 1 Ravi NULL; 2 Priya 1; 3 Arun 1; 4 Meena 2; 5 Kiran 4.

WITH RECURSIVE tree AS (
    SELECT id, name, mgr_id, 1 AS lvl, CAST(name AS VARCHAR(200)) AS path
    FROM staff WHERE mgr_id IS NULL
  UNION ALL
    SELECT s.id, s.name, s.mgr_id, t.lvl + 1, CONCAT(t.path, ' > ', s.name)
    FROM staff s JOIN tree t ON s.mgr_id = t.id)
SELECT lvl, path FROM tree ORDER BY path;

Iterations: anchor → 1 row (Ravi, lvl 1); iteration 1 → Priya, Arun (lvl 2); iteration 2 → Meena (lvl 3, path Ravi > Priya > Meena); iteration 3 → Kiran (lvl 4); iteration 4 → 0 rows, stop. 5 rows, maximum depth 4.

Example 5 — the NOT IN trap. customer(id) = {1, 2, 3}; orders(cust_id) = {1, 2, NULL}.

Comparison table

Subquery / derived tableCTEWindow functionTemp table
Named and reusableNoYes, within one statementn/aYes, for the session
Collapses rowsYes, if aggregatingDepends on its bodyNon/a
RecursionNoYes (WITH RECURSIVE)NoVia loops only
Can be indexedNoNoNoYes
Typical useFilter, lookup, inline viewReadable multi-step logic, hierarchiesRank, running total, growth, top-NLarge reused intermediate

Where it is used

Interview questions

Common mistakes

Related topics

SQL queries and joins · ER model and normalisation · Query processing and optimisation · Transactions, ACID and indexing · NoSQL databases · Database design case studies

Open in the Knowledge reader →Find jobs →