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 to Identify Top-N Records Per Group: Filtering with Subqueries and Row Ranking Without Window Functions

Identifying the top-N records within each group is one of the most common — and most misunderstood — SQL challenges. This lesson teaches two production-ready techniques using correlated subqueries and self-joins, so you can solve this problem confidently on any database without window functions.

⚡ Practitioner23 min readOct 12, 2026Updated Oct 12, 2026
Writing SQL to Identify Top-N Records Per Group: Filtering with Subqueries and Row Ranking Without Window Functions
On this page
  • Introduction
  • Prerequisites
  • The Core Problem: Why Simple Filtering Fails
  • Setting Up the Dataset
  • Approach 1: The Correlated Subquery Method
  • Understanding the Tie-Breaking Behavior
  • Approach 2: The Self-Join Aggregation Method
  • Comparing the Two Approaches
  • Applying This to Multi-Table Scenarios
  • Handling Real-World Ranking Complications
  • Finding Top-N by a Computed Column
  • Getting Exactly the Bottom N (Worst Performers)
  • Ranking by Most Recent Date (Top-N Temporal)
  • Performance Implications
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Using INNER JOIN Instead of LEFT JOIN in the Self-Join Method
  • Mistake 2: Forgetting to Scope the Subquery to the Same Group
  • Mistake 3: Off-by-One in the Threshold
  • Mistake 4: Missing GROUP BY Columns in the Self-Join Approach
  • Mistake 5: Correlated Subquery with NULLs in the Ranking Column
  • A Real-World Project: Category Performance Report
  • Summary & Next Steps
  • Writing SQL to Identify Top-N Records Per Group: Filtering with Subqueries and Row Ranking Without Window Functions

    Introduction

    Picture this: your manager asks for the top 3 best-selling products in each product category for last quarter. You know how to find the single best-seller across all categories — that's just ORDER BY revenue DESC LIMIT 3. But per category? That's a different problem entirely, and it's one of those queries that stumps even experienced SQL writers the first time they encounter it.

    The "Top-N per group" problem appears constantly in real analytics work. Who are the top 5 customers by lifetime value in each region? What are the 3 most recent orders per customer? Which salesperson ranked first in each territory last month? These questions share a common structure: you need to rank or filter within partitions of your data, then keep only the top performers from each partition.

    Modern SQL has a beautiful solution: window functions like ROW_NUMBER() and RANK(). But here's the practical reality — window functions aren't available in every database version, every team has legacy systems, and more importantly, understanding how to solve this problem without window functions will deepen your mastery of subqueries, joins, and aggregation more than almost any other exercise. By the end of this lesson, you'll have two clean techniques for finding top-N records per group using subqueries and self-joins, you'll understand exactly why they work, and you'll know when to reach for each approach.

    What you'll learn:

    • How to frame the Top-N per group problem in relational terms
    • Using correlated subqueries to filter rows by their rank within a group
    • Using self-joins with aggregate counting to simulate row ranking
    • How to handle ties and near-ties correctly
    • Performance implications and when subquery ranking becomes expensive
    • A realistic multi-table project combining joins, aggregation, and subquery ranking

    Prerequisites

    You should be comfortable writing SELECT, WHERE, GROUP BY, and JOIN queries before diving in. You should also understand how subqueries work — particularly correlated subqueries where the inner query references the outer query's rows. If subqueries feel shaky, review Understanding SQL Subqueries: Filtering and Looking Up Data with Nested SELECT Statements before continuing. You don't need any experience with window functions.


    The Core Problem: Why Simple Filtering Fails

    Let's set up a realistic scenario we'll use throughout the lesson. You work at a retail company, and you have an orders table that looks like this:

    -- orders table
    -- order_id | customer_id | category   | product_name          | revenue  | order_date
    -- 1001     | 42          | Electronics| Wireless Headphones   | 189.99   | 2024-01-15
    -- 1002     | 17          | Electronics| USB-C Hub             |  49.99   | 2024-01-16
    -- 1003     | 88          | Electronics| Wireless Headphones   | 189.99   | 2024-01-17
    -- 1004     | 42          | Apparel    | Running Shoes         | 129.99   | 2024-01-18
    -- 1005     | 55          | Apparel    | Compression Socks     |  24.99   | 2024-01-19
    -- ...
    

    You want the top 2 products by total revenue within each category. Your first instinct might be to reach for GROUP BY and HAVING:

    -- This does NOT solve the problem
    SELECT category, product_name, SUM(revenue) AS total_revenue
    FROM orders
    GROUP BY category, product_name
    HAVING SUM(revenue) >= 500;
    

    This fails for a simple reason: the HAVING threshold is arbitrary and category-agnostic. A threshold of $500 might give you 4 products in Electronics and 0 in Apparel. You'd need to know the cutoff per category in advance, and if the data changes, your query breaks.

    The fundamental issue is that SQL's WHERE and HAVING clauses filter rows globally — they don't have a built-in concept of "the top N rows within each group." That's the gap we're filling.

    Key insight

    The Top-N per group problem requires relative ranking (this row is ranked 2nd within its category), but standard WHERE filters are absolute (this row meets a condition regardless of context). Solving it means simulating relative ranking using tools that SQL does have: subqueries and joins.


    Setting Up the Dataset

    Let's create a clean working dataset. We'll use a product_sales table that aggregates revenue per product per category, which is a natural starting point for category rankings:

    CREATE TABLE product_sales (
        sale_id     INT PRIMARY KEY,
        category    VARCHAR(50),
        product_name VARCHAR(100),
        total_revenue DECIMAL(10,2),
        units_sold  INT
    );
    
    INSERT INTO product_sales VALUES
    (1,  'Electronics', 'Wireless Headphones',    4250.00, 22),
    (2,  'Electronics', 'Bluetooth Speaker',       3890.00, 19),
    (3,  'Electronics', 'USB-C Hub',               2100.00, 42),
    (4,  'Electronics', 'Portable Charger',        1750.00, 35),
    (5,  'Electronics', 'Smart Watch Band',         980.00, 49),
    (6,  'Apparel',     'Running Shoes',           5100.00, 34),
    (7,  'Apparel',     'Compression Socks',       1200.00, 96),
    (8,  'Apparel',     'Performance T-Shirt',     2750.00, 55),
    (9,  'Apparel',     'Insulated Jacket',        4800.00, 24),
    (10, 'Apparel',     'Trail Shorts',             875.00, 35),
    (11, 'Home',        'Air Purifier',            3600.00, 12),
    (12, 'Home',        'Robot Vacuum',            6200.00, 10),
    (13, 'Home',        'Smart Thermostat',        2900.00, 18),
    (14, 'Home',        'Weighted Blanket',        1450.00, 29),
    (15, 'Home',        'Ceramic Knife Set',        960.00, 32);
    

    We have three categories (Electronics, Apparel, Home), each with 5 products. Our goal: retrieve the top 3 products by revenue within each category.

    The expected result should be:

    • Electronics: Wireless Headphones ($4,250), Bluetooth Speaker ($3,890), USB-C Hub ($2,100)
    • Apparel: Running Shoes ($5,100), Insulated Jacket ($4,800), Performance T-Shirt ($2,750)
    • Home: Robot Vacuum ($6,200), Air Purifier ($3,600), Smart Thermostat ($2,900)

    Now let's build two different approaches to get there.


    Approach 1: The Correlated Subquery Method

    This is the most conceptually elegant solution. The idea: for each row, count how many other rows in the same category have higher revenue. If that count is less than N, this row is in the top N.

    Think about it for a moment. If Wireless Headphones ($4,250) is the best-seller in Electronics, then zero other Electronics products have higher revenue. Its count is 0, which is less than 3 — so it qualifies for top 3. Bluetooth Speaker ($3,890) has one product ahead of it (Wireless Headphones), so its count is 1 — still less than 3, it qualifies. USB-C Hub ($2,100) has two products ahead of it, count is 2 — qualifies. Portable Charger ($1,750) has three ahead of it, count is 3 — that's NOT less than 3, so it's excluded.

    Here's that logic in SQL:

    SELECT
        ps_outer.category,
        ps_outer.product_name,
        ps_outer.total_revenue,
        ps_outer.units_sold
    FROM product_sales ps_outer
    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.category = ps_outer.category
          AND ps_inner.total_revenue > ps_outer.total_revenue
    ) < 3
    ORDER BY ps_outer.category, ps_outer.total_revenue DESC;
    

    Let's trace through what's happening:

    1. The outer query scans every row in product_sales (aliased as ps_outer)
    2. For each row, the correlated subquery counts how many rows in the same category (ps_inner.category = ps_outer.category) have a strictly higher revenue (ps_inner.total_revenue > ps_outer.total_revenue)
    3. The WHERE condition keeps only rows where that count is less than 3

    Run this and you get exactly the 9 rows we expected — 3 per category, sorted by revenue descending within each category.

    Note

    The subquery is called "correlated" because it references a column from the outer query (ps_outer.category and ps_outer.total_revenue). It re-executes for every row the outer query processes. For large tables, this can be slow — we'll cover performance implications in a dedicated section below.

    Understanding the Tie-Breaking Behavior

    Notice that the subquery uses strict greater-than (>), not >=. This has important implications for ties.

    Imagine two products in Electronics both have revenue of $2,100. Both would count the same number of rows ahead of them, so both would qualify for top 3. If your top-3 slots are already filled by two genuinely higher-ranked products, you could end up with 4 results for Electronics — not 3.

    This is actually the correct behavior in many business contexts. If two products tie for 3rd place, you probably want both included. This is called "dense ranking" — you show all tied competitors for a given slot.

    But if you need exactly N rows per group regardless of ties, you need to add a tiebreaker. The cleanest approach is to add a secondary sort column to the subquery:

    SELECT
        ps_outer.category,
        ps_outer.product_name,
        ps_outer.total_revenue,
        ps_outer.units_sold
    FROM product_sales ps_outer
    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.category = ps_outer.category
          AND (
              ps_inner.total_revenue > ps_outer.total_revenue
              OR (ps_inner.total_revenue = ps_outer.total_revenue
                  AND ps_inner.sale_id < ps_outer.sale_id)
          )
    ) < 3
    ORDER BY ps_outer.category, ps_outer.total_revenue DESC;
    

    This says: "count rows that are ahead of me, where ahead means either higher revenue, or equal revenue but a lower sale_id." With a unique tiebreaker like sale_id, you always get exactly N rows per group.

    Warning

    If you're working with nullable columns in your ranking logic, > comparisons involving NULL always evaluate to FALSE in SQL — NULL > 100 returns NULL, not TRUE or FALSE. This means rows with NULL revenue will always count zero rows ahead of them and appear to qualify for any top-N cutoff. Always use COALESCE or NULLIF to handle nulls explicitly before ranking.


    Approach 2: The Self-Join Aggregation Method

    The second technique achieves the same result through a completely different mechanism: joining the table to itself and counting competitors directly in the GROUP BY.

    This approach is worth understanding because it expresses the ranking logic through joins rather than nested subqueries, which can be easier to optimize on certain database engines and sometimes more readable to SQL practitioners who think in terms of relational algebra.

    The idea: join every product to all products in the same category that outrank it. Then group by product and count how many outrankers it has. Filter where that count is below your threshold.

    SELECT
        ps1.category,
        ps1.product_name,
        ps1.total_revenue,
        ps1.units_sold,
        COUNT(ps2.sale_id) AS products_ranked_higher
    FROM product_sales ps1
    LEFT JOIN product_sales ps2
        ON ps1.category = ps2.category
       AND ps2.total_revenue > ps1.total_revenue
    GROUP BY
        ps1.category,
        ps1.product_name,
        ps1.total_revenue,
        ps1.units_sold
    HAVING COUNT(ps2.sale_id) < 3
    ORDER BY ps1.category, ps1.total_revenue DESC;
    

    Let's trace through the join logic step by step.

    For Wireless Headphones (the top Electronics product at $4,250): when we join ps2 to find Electronics products with revenue greater than $4,250, nothing matches. The LEFT JOIN keeps the row but ps2.sale_id is NULL, so COUNT(ps2.sale_id) returns 0. The HAVING clause keeps rows where that count is less than 3, so Wireless Headphones passes.

    For Portable Charger ($1,750): three Electronics products beat it — Wireless Headphones, Bluetooth Speaker, and USB-C Hub. The join produces 3 matching ps2 rows, COUNT(ps2.sale_id) is 3, and HAVING COUNT(ps2.sale_id) < 3 is FALSE, so Portable Charger is excluded.

    Key insight

    The LEFT JOIN here is critical, not an INNER JOIN. If you use INNER JOIN, any row with zero competitors would be dropped entirely — meaning your #1 product in each category would disappear from the results. Always use LEFT JOIN when you're counting competitors that may not exist. For a deeper dive on this distinction, see Choosing the Right JOIN Type: When to Use INNER, LEFT, and FULL OUTER JOIN for Clean Aggregation Results.

    Comparing the Two Approaches

    Both approaches return the same results for our dataset. But they have different strengths:

    Aspect Correlated Subquery Self-Join + GROUP BY
    Readability Very clear logic Slightly more JOIN/GROUP BY complexity
    Performance Re-executes per row Single pass join, then group
    Ties handling Straightforward to adjust Requires careful COUNT/HAVING logic
    Index usage Benefits from index on (category, total_revenue) Benefits from same index
    Optimizer behavior Varies widely by database Generally more predictable

    For small-to-medium tables (under ~100,000 rows), both work fine. For large datasets, the self-join approach often gives the query optimizer more options to work with, though this varies significantly by database engine and index structure.


    Applying This to Multi-Table Scenarios

    Real-world Top-N problems rarely involve a single pre-aggregated table. More often, you're working with normalized data across multiple tables. Let's make this more realistic.

    Suppose you have a proper orders schema:

    -- customers table
    CREATE TABLE customers (
        customer_id  INT PRIMARY KEY,
        customer_name VARCHAR(100),
        region       VARCHAR(50)
    );
    
    -- orders table
    CREATE TABLE orders (
        order_id    INT PRIMARY KEY,
        customer_id INT,
        order_date  DATE,
        total_amount DECIMAL(10,2)
    );
    

    Your goal: find the top 2 customers by total spending within each region.

    This requires two steps:

    1. Aggregate orders per customer to get their total spend
    2. Apply top-N filtering within each region

    The cleanest way to handle multi-step problems like this is with a derived table (an inline subquery in the FROM clause) for the aggregation step, then the correlated subquery approach for ranking. If you want to learn more about structuring queries this way, Writing SQL FROM Scratch: Structuring Multi-Step Analytical Queries with Derived Tables and Inline Views covers this pattern in depth.

    Here's the full query:

    SELECT
        customer_totals.region,
        customer_totals.customer_name,
        customer_totals.lifetime_spend
    FROM (
        -- Step 1: aggregate orders per customer, join to get region and name
        SELECT
            c.customer_id,
            c.customer_name,
            c.region,
            SUM(o.total_amount) AS lifetime_spend
        FROM customers c
        JOIN orders o ON c.customer_id = o.customer_id
        GROUP BY c.customer_id, c.customer_name, c.region
    ) AS customer_totals
    WHERE (
        -- Step 2: correlated subquery counts higher-spending customers in same region
        SELECT COUNT(*)
        FROM (
            SELECT
                c2.customer_id,
                c2.region,
                SUM(o2.total_amount) AS lifetime_spend
            FROM customers c2
            JOIN orders o2 ON c2.customer_id = o2.customer_id
            GROUP BY c2.customer_id, c2.region
        ) AS inner_totals
        WHERE inner_totals.region = customer_totals.region
          AND inner_totals.lifetime_spend > customer_totals.lifetime_spend
    ) < 2
    ORDER BY customer_totals.region, customer_totals.lifetime_spend DESC;
    

    This query has three layers of nesting, and that's perfectly normal for this class of problem. Notice that the aggregation subquery is duplicated — once in the outer FROM and once inside the correlated WHERE. This duplication is the main cost of the correlated subquery approach on complex data; the aggregation runs twice per outer row.

    Tip

    If your database supports Common Table Expressions (CTEs), you can define the aggregation once as a CTE and reference it in both the outer query and the correlated subquery. This improves readability significantly and, in databases that materialize CTEs, can also improve performance. Check out Advanced Subqueries and CTEs: Mastering Complex SQL Query Architecture for the CTE-based pattern.


    Handling Real-World Ranking Complications

    Finding Top-N by a Computed Column

    Sometimes your ranking column doesn't exist directly in the table — it's computed. For example, you want the top 2 products per category by revenue per unit (a calculated efficiency metric):

    SELECT
        ps_outer.category,
        ps_outer.product_name,
        ps_outer.total_revenue,
        ps_outer.units_sold,
        ROUND(ps_outer.total_revenue / ps_outer.units_sold, 2) AS revenue_per_unit
    FROM product_sales ps_outer
    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.category = ps_outer.category
          AND (ps_inner.total_revenue / ps_inner.units_sold)
              > (ps_outer.total_revenue / ps_outer.units_sold)
    ) < 2
    ORDER BY ps_outer.category,
             (ps_outer.total_revenue / ps_outer.units_sold) DESC;
    

    Note that the computed expression total_revenue / units_sold must appear in the subquery's WHERE condition — you can't reference a column alias defined in the outer SELECT. This is one of the frustrations of working without window functions; the calculation needs to be repeated in every context where it's used. The lesson on writing reusable SQL column logic discusses strategies for minimizing this kind of repetition.

    Getting Exactly the Bottom N (Worst Performers)

    To find the bottom N records per group instead of the top N, flip the comparison operator in the correlated subquery from > to <:

    -- Bottom 2 products by revenue per category
    SELECT
        ps_outer.category,
        ps_outer.product_name,
        ps_outer.total_revenue
    FROM product_sales ps_outer
    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.category = ps_outer.category
          AND ps_inner.total_revenue < ps_outer.total_revenue  -- NOTE: < instead of >
    ) < 2
    ORDER BY ps_outer.category, ps_outer.total_revenue ASC;
    

    This counts how many products in the same category have lower revenue. If that count is less than 2, this product is in the bottom 2.

    Ranking by Most Recent Date (Top-N Temporal)

    Another very common pattern is "the N most recent transactions per customer." The ranking column is a date, not a revenue figure:

    -- Most recent 3 orders per customer
    SELECT
        o_outer.customer_id,
        o_outer.order_id,
        o_outer.order_date,
        o_outer.total_amount
    FROM orders o_outer
    WHERE (
        SELECT COUNT(*)
        FROM orders o_inner
        WHERE o_inner.customer_id = o_outer.customer_id
          AND o_inner.order_date > o_outer.order_date
    ) < 3
    ORDER BY o_outer.customer_id, o_outer.order_date DESC;
    

    The same pattern, with order_date as the ranking column. The subquery counts how many orders for the same customer have a later date. Any order with fewer than 3 later orders is in the top 3 most recent.

    Warning

    If two orders for the same customer share the same order_date, both will count identical numbers of rows ahead of them, and you may get more than N results for that customer. Add order_id as a tiebreaker (earlier order IDs lose the tiebreak) the same way we handled tied revenues earlier.


    Performance Implications

    Let's be honest about the cost of these approaches. The correlated subquery re-executes for every row in the outer query. On a table with 10,000 products across 200 categories, the subquery might run 10,000 times, each time scanning up to 50 rows. That's potentially 500,000 row comparisons.

    The self-join approach avoids repeated re-execution by doing a single join, but a join between 10,000 rows and itself still produces up to 100,000,000 row combinations before filtering — though the database optimizer can use indexes to prune this heavily.

    The most important performance lever: indexing the grouping and ranking columns together.

    For our product_sales example:

    -- Composite index on the two columns used for grouping and ranking
    CREATE INDEX idx_product_sales_category_revenue
    ON product_sales (category, total_revenue DESC);
    

    With this index, the database can seek directly to all rows in a given category and scan them in revenue order — the exact access pattern both approaches need. Without this index, every subquery execution or join partition requires a full table scan. For a deeper understanding of how index structure affects query performance, SQL Indexes Explained: How They Work and When to Create Them is the right follow-up.

    When is the performance cost acceptable?

    • Reporting queries run on small-to-medium tables (< 500K rows): both approaches work fine
    • Dashboards hitting large tables frequently: consider pre-aggregating and materializing results in a summary table
    • One-time analytical queries: correctness matters more than performance; use whichever approach is clearest
    • OLTP systems serving user-facing requests: avoid correlated subquery rankings altogether; use application-level paging or pre-compute rankings

    Hands-On Exercise

    Work through this exercise to cement the techniques. Use the product_sales table we created earlier.

    Exercise 1: Top-2 by Units Sold Per Category

    Write a query using the correlated subquery method to find the top 2 products by units_sold (not revenue) within each category. Handle ties by using sale_id as a tiebreaker (lower sale_id wins in a tie).

    Expected results:

    • Electronics: USB-C Hub (42 units), Smart Watch Band (49 units) — wait, sort descending and you'll see Smart Watch Band (49) and USB-C Hub (42)
    • Apparel: Compression Socks (96), Performance T-Shirt (55)
    • Home: Ceramic Knife Set (32) and Weighted Blanket (29)

    Exercise 2: Bottom-1 Revenue Product Per Category

    Using the self-join aggregation method, find the single worst-performing product by revenue in each category.

    Expected results:

    • Electronics: Smart Watch Band ($980)
    • Apparel: Trail Shorts ($875)
    • Home: Ceramic Knife Set ($960)

    Exercise 3: Multi-Metric Ranking

    Write a query that returns the top 2 products per category, ranked by a composite score of (total_revenue * 0.7) + (units_sold * 10 * 0.3). This simulates a weighted business priority score.

    For this one, you'll need to write the composite expression in both the outer SELECT and the inner correlated subquery's WHERE condition.

    Bonus challenge: Extend Exercise 3 to include a column showing each product's rank within its category (1, 2, 3, etc.) without using any window functions. Hint: the rank equals the count from the correlated subquery plus 1.

    -- Bonus hint structure:
    SELECT
        ps_outer.category,
        ps_outer.product_name,
        ps_outer.total_revenue,
        (
            SELECT COUNT(*) + 1
            FROM product_sales ps_inner
            WHERE ps_inner.category = ps_outer.category
              AND ps_inner.total_revenue > ps_outer.total_revenue
        ) AS rank_in_category
    FROM product_sales ps_outer
    -- Add your WHERE clause here to filter top-N
    ORDER BY ps_outer.category, rank_in_category;
    

    This is the key insight for the bonus: the count-of-rows-ahead-of-me, plus 1, is the rank. You can include it as a SELECT column at the same time you use it as a WHERE filter. If you want to explore how to build these kinds of multi-step analytical patterns further, Writing Multi-Step Analytical Queries: Chaining Subqueries, JOINs, and GROUP BY to Answer Real Business Questions has more exercises like this.


    Common Mistakes & Troubleshooting

    Mistake 1: Using INNER JOIN Instead of LEFT JOIN in the Self-Join Method

    Symptom: Your top-ranked item in each group is missing from results.

    Why it happens: The number-one product in each group has zero competitors with higher revenue. An INNER JOIN requires at least one matching row in ps2, so when no competitors exist, the row is dropped entirely.

    Fix: Always use LEFT JOIN when joining to count competitors that may not exist.

    Mistake 2: Forgetting to Scope the Subquery to the Same Group

    Symptom: Your query returns only the global top-N across all groups, not the top-N per group.

    Broken query:

    -- WRONG: missing the category filter in the subquery
    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.total_revenue > ps_outer.total_revenue
        -- Missing: AND ps_inner.category = ps_outer.category
    ) < 3
    

    Without AND ps_inner.category = ps_outer.category, the subquery counts all products globally with higher revenue. A product ranked 3rd in Electronics would be counted against products from all other categories and might be excluded even though it's legitimately top-3 in its own group.

    Fix: Always include the group-equality condition in the correlated subquery.

    Mistake 3: Off-by-One in the Threshold

    Symptom: You get top-4 instead of top-3, or top-2 instead of top-3.

    Why it happens: Confusing < N vs <= N. If you want the top 3, you keep rows where the count of higher-ranked rows is strictly less than 3 (meaning 0, 1, or 2 rows ahead). Using <= 3 would keep rows where up to 3 other rows are ahead, giving you 4 results per group.

    Count of rows with higher revenue = 0 → Rank 1 → keep if threshold is < N (N ≥ 1)
    Count of rows with higher revenue = 1 → Rank 2 → keep if threshold is < N (N ≥ 2)
    Count of rows with higher revenue = 2 → Rank 3 → keep if threshold is < N (N ≥ 3)
    Count of rows with higher revenue = 3 → Rank 4 → excluded when threshold is < 3
    

    Rule: To get top-N, use COUNT(*) < N.

    Mistake 4: Missing GROUP BY Columns in the Self-Join Approach

    Symptom: Database error like "column must appear in GROUP BY or aggregate function."

    Fix: Every column from ps1 that appears in your SELECT must appear in your GROUP BY. Since you're not aggregating ps1 columns (only counting ps2 matches), you need to list all of them:

    GROUP BY
        ps1.sale_id,       -- include even the PK
        ps1.category,
        ps1.product_name,
        ps1.total_revenue,
        ps1.units_sold
    

    Mistake 5: Correlated Subquery with NULLs in the Ranking Column

    Symptom: Products with NULL revenue appear in top-N results even though they shouldn't, or products you expect to see are missing.

    Why it happens: NULL > 4250 evaluates to NULL (not FALSE), so rows with NULL revenue count zero competitors ahead of them and qualify for every top-N cutoff.

    Fix: Filter out NULLs explicitly, or use COALESCE to substitute a sentinel value:

    WHERE (
        SELECT COUNT(*)
        FROM product_sales ps_inner
        WHERE ps_inner.category = ps_outer.category
          AND COALESCE(ps_inner.total_revenue, 0) > COALESCE(ps_outer.total_revenue, 0)
    ) < 3
    AND ps_outer.total_revenue IS NOT NULL
    

    Tip

    If you're debugging unexpected results from your Top-N query, start by running just the correlated subquery in isolation for a few specific outer rows. Replace the outer table reference with a hard-coded value and check what the subquery returns. This step-by-step decomposition is covered in Debugging SQL Queries: How to Read Error Messages, Trace Wrong Results, and Fix Broken Joins Step by Step.


    A Real-World Project: Category Performance Report

    Let's put everything together in a single realistic deliverable: a category performance report that shows the top 3 products per category, including a computed rank column and a comparison to the category average.

    SELECT
        ranked.category,
        ranked.rank_in_category,
        ranked.product_name,
        ranked.total_revenue,
        ranked.units_sold,
        ROUND(ranked.total_revenue / ranked.units_sold, 2) AS revenue_per_unit,
        ROUND(cat_avg.avg_category_revenue, 2)             AS category_avg_revenue,
        ROUND(
            (ranked.total_revenue - cat_avg.avg_category_revenue)
            / cat_avg.avg_category_revenue * 100,
            1
        ) AS pct_above_category_avg
    FROM (
        -- Outer query: select products with their rank, filtered to top 3
        SELECT
            ps_outer.sale_id,
            ps_outer.category,
            ps_outer.product_name,
            ps_outer.total_revenue,
            ps_outer.units_sold,
            (
                SELECT COUNT(*) + 1
                FROM product_sales ps_inner
                WHERE ps_inner.category = ps_outer.category
                  AND ps_inner.total_revenue > ps_outer.total_revenue
            ) AS rank_in_category
        FROM product_sales ps_outer
        WHERE (
            SELECT COUNT(*)
            FROM product_sales ps_inner
            WHERE ps_inner.category = ps_outer.category
              AND ps_inner.total_revenue > ps_outer.total_revenue
        ) < 3
    ) AS ranked
    JOIN (
        -- Per-category average revenue
        SELECT
            category,
            AVG(total_revenue) AS avg_category_revenue
        FROM product_sales
        GROUP BY category
    ) AS cat_avg ON ranked.category = cat_avg.category
    ORDER BY ranked.category, ranked.rank_in_category;
    

    This query does several things at once:

    • Ranks all products within their category using the correlated subquery approach
    • Filters to only the top 3 per category
    • Adds an explicit rank column (count + 1)
    • Joins to a derived table containing per-category averages
    • Computes how much each top-3 product outperforms its category average

    The result is a complete, business-ready report showing not just who the winners are, but how dominant they are relative to their peers.

    For how to structure queries with this level of complexity into readable layers, Building a Complete Analytical Query from Scratch: Combining SELECT, JOIN, GROUP BY, and Subqueries to Answer a Multi-Part Business Question walks through the same layering process in detail.


    Summary & Next Steps

    You now have two solid, production-ready techniques for solving the Top-N per group problem without window functions.

    The correlated subquery approach works by counting how many rows in the same group outrank the current row. If that count is less than N, the row qualifies. It's clean to read, easy to extend with tiebreakers, and the ranking count doubles as an explicit rank column when you add 1 to it.

    The self-join aggregation approach achieves the same goal by joining the table to itself on the group key with a ranking inequality, then grouping and filtering with HAVING. It's slightly more verbose but can give the query optimizer more flexibility on large datasets.

    Both approaches share the same core insight: simulate a relative rank by counting competitors within the same group. Everything else — computed ranking columns, temporal rankings, bottom-N, multi-metric composite scores — is a variation on this foundation.

    Key things to remember:

    • Use COUNT(*) < N (not <= N) to get exactly N results when there are no ties
    • Always scope your subquery or join to the same group (ps_inner.category = ps_outer.category)
    • Use LEFT JOIN (not INNER JOIN) in the self-join method so top-ranked rows aren't dropped
    • Add a unique tiebreaker column when you need exactly N rows regardless of ties
    • Index on (group_column, ranking_column) for meaningful performance gains

    Where to go next: Once you're comfortable with these subquery-based ranking techniques, the natural next step is learning window functions — specifically ROW_NUMBER(), RANK(), and DENSE_RANK(). They solve the exact same problem with dramatically cleaner syntax and often better performance. But now that you understand the mechanics underneath, window functions will make much more sense because you'll know what they're abstracting away.

    In the meantime, if you want to push your subquery skills further, explore the Mastering SQL EXISTS and NOT EXISTS: Correlated Subquery Patterns for Filtering with Related Data lesson — EXISTS is another powerful correlated subquery pattern that pairs naturally with the ranking techniques you just learned.

    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

    Writing SQL Aggregate Expressions Without GROUP BY: Using COUNT, SUM, and AVG Across an Entire Result Set

    Related Insights

    SQLFoundation

    Writing SQL Aggregate Expressions Without GROUP BY: Using COUNT, SUM, and AVG Across an Entire Result Set

    16 min
    SQLExpert

    Querying Hierarchical Data Without Recursion: Using Self-Joins and Subqueries to Traverse Parent-Child Relationships

    26 min
    SQLPractitioner

    Calculating Running Totals and Cumulative Sums with GROUP BY and Subqueries

    20 min

    On this page

    • Introduction
    • Prerequisites
    • The Core Problem: Why Simple Filtering Fails
    • Setting Up the Dataset
    • Approach 1: The Correlated Subquery Method
    • Understanding the Tie-Breaking Behavior
    • Approach 2: The Self-Join Aggregation Method
    • Comparing the Two Approaches
    • Applying This to Multi-Table Scenarios
    • Handling Real-World Ranking Complications
    • Finding Top-N by a Computed Column
    • Getting Exactly the Bottom N (Worst Performers)
    • Ranking by Most Recent Date (Top-N Temporal)
    • Performance Implications
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Using INNER JOIN Instead of LEFT JOIN in the Self-Join Method
    • Mistake 2: Forgetting to Scope the Subquery to the Same Group
    • Mistake 3: Off-by-One in the Threshold
    • Mistake 4: Missing GROUP BY Columns in the Self-Join Approach
    • Mistake 5: Correlated Subquery with NULLs in the Ranking Column
    • A Real-World Project: Category Performance Report
    • Summary & Next Steps