100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
SQL Mastery
60 minbeginner

Top Performers Ranking Query

What You'll Build

In this exercise you will build an analytical query suite that ranks IPL performers using subqueries and CTEs together. Working from a deliveries table, you will find batsmen above benchmarks with scalar and correlated subqueries, identify players with and without certain achievements using EXISTS and NOT EXISTS, and assemble a clean ranked leaderboard through a chained CTE pipeline that reads as a logical sequence.

By the end you will have a single SQL file that answers genuinely sophisticated questions: who outscored their own team's average, which players have never been dismissed, and the staged head-to-head and ranking computations that a real cricket analyst produces. This mirrors how subqueries and CTEs combine in practice to break multi-step analysis into readable, verifiable pieces.

The emphasis is on choosing the right construct for each question and composing them cleanly. You will use a scalar subquery for a global benchmark, a correlated subquery for per-team comparison, NOT EXISTS to dodge the NULL trap, and CTEs to stage the leaderboard. Predict each query's output before running, then verify, so the layered logic is something you trust rather than hope works.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of the analytics team assembling the broadcast's end-of-day statistics package. Just as they compile separate panels, top scorers, team totals, venue records, and trend graphs, from the same ball-by-ball data, you build separate grouped queries from one deliveries table. Just as each panel must be accurate before it airs to millions, each query must be verified before it feeds the dashboard. Just as the package tells the day's story through numbers, your query layer turns raw deliveries into the season's narrative. Just as a wrong figure on air is caught by cross-checking panels against each other, team totals summing to the tournament total, your queries can be validated against one another for consistency. Just as the package is rebuilt fresh from the feed each day, every dashboard number is derived from raw rows at query time, never hand-edited. This reveals why disciplined aggregation is the backbone of every stats broadcast.

Prerequisites

  • A working PostgreSQL setup with the psql client, as built in the Module 1 exercise, ready to run SQL files against a local database.
  • Completion of this module's lessons on scalar and inline subqueries, correlated subqueries with EXISTS, CTEs, and recursive CTEs, all applied below.
  • Comfort with aggregates, GROUP BY, and joins from Modules 2 and 3, since the ranking queries combine grouping and subqueries together.
  • A text editor to maintain a single ranking_lab.sql file so the whole schema and query suite stays reproducible and easy to rerun.

Setup & Project Structure

You will create a fresh database and a deliveries table carrying batsman, team, runs, and a nullable dismissal so EXISTS and NOT EXISTS have meaningful data. The schema lives in a rerunnable script and all analytical queries live in one file, keeping the whole lab reproducible. A few not-out players and a clear spread of scores give the benchmark and ranking queries real variety to work with.

Analogy🏏Cricket
🏏 Think of it like cricket: Building the deliveries table with season, match_date, and dismissal columns is like laying out a season-long scorebook designed for every question you will ask. Just as a good scorebook records not only runs but the date and manner of each dismissal so any summary can be drawn later, your enriched table carries the extra columns every aggregation and date query will need. Just as a scorer keeps the blank scorebook template so next season starts identically, your schema lives in a rerunnable script. Just as all the tournament's summary graphics are produced from that one book, every dashboard panel lives in one queries file you can run end to end. Just as reopening the book regenerates any figure on demand, rerunning the file regenerates every number at once. This reveals why a well-designed table and scripted panels make a dashboard reproducible.

This single-table design is deliberate: subqueries and CTEs shine at multi-stage analysis over a body of facts, so one rich table is enough to practise scalar benchmarks, per-group correlation, and staged ranking. Keeping schema and queries as version-controlled scripts reflects real reporting practice, where the analysis is reproducible and auditable rather than typed ad hoc into a shell.

bash
# Fresh database for the ranking lab.
dropdb ipl_ranking 2>/dev/null; createdb ipl_ranking
mkdir -p ipl_ranking_lab && cd ipl_ranking_lab
touch schema.sql       # deliveries table, seeded
touch ranking_lab.sql  # subquery + CTE analytical queries

psql ipl_ranking -f schema.sql
psql ipl_ranking -f ranking_lab.sql

# Quick check:
psql ipl_ranking -c "SELECT COUNT(*) FROM deliveries;"   # expect 10

Step 1 — Foundation

Step one defines and seeds the deliveries table. It carries batsman, team_name, runs_scored, and a nullable dismissal, plus enough rows across three teams to make averages and rankings meaningful. Deliberate not-out rows, where dismissal is NULL, give the EXISTS and NOT EXISTS queries something real to detect, and a CHECK constraint keeps runs valid so your aggregates are trustworthy.

Running this schema once establishes the reproducible foundation for every analytical query that follows. As in earlier modules, defining clear types and constraints up front means the subqueries and CTEs you layer on top operate over clean, validated data, so any surprising result points to your query logic rather than to malformed input.

Analogy🏏Cricket
🏏 Think of it like cricket: Picture the groundstaff and scorers preparing before the season opener. Just as they set up the pitch, boundary, and a fresh scorebook with columns for every stat to be recorded, the schema lays out typed columns for runs, dates, and dismissals. Just as a complete scorebook lets every later statistic be computed accurately, a complete table lets every dashboard panel aggregate correctly. Just as the same setup repeats reliably each match, rerunning the schema rebuilds identical data. Just as the scorers agree before the first ball how a wide or a no-ball will be recorded, the schema fixes types and NULL conventions up front so every later aggregate interprets the data identically. Just as an incomplete scorebook column ruins every statistic that depends on it, a mistyped or missing column undermines every panel built on the table. This shows why a thorough foundation makes every later summary dependable.
sql
-- schema.sql : run with  psql ipl_ranking -f schema.sql
DROP TABLE IF EXISTS deliveries;

CREATE TABLE deliveries (
    delivery_id INTEGER PRIMARY KEY,
    batsman     TEXT    NOT NULL,
    team_name   TEXT    NOT NULL,
    runs_scored INTEGER NOT NULL CHECK (runs_scored >= 0),
    dismissal   TEXT          -- NULL means not out
);

INSERT INTO deliveries VALUES
    (1, 'Rohit Sharma',     'Mumbai Indians',      72, NULL),
    (2, 'Suryakumar Yadav', 'Mumbai Indians',      61, 'caught'),
    (3, 'Tilak Varma',      'Mumbai Indians',      28, 'bowled'),
    (4, 'Virat Kohli',      'Royal Challengers',   45, 'caught'),
    (5, 'Faf du Plessis',   'Royal Challengers',   33, NULL),
    (6, 'Glenn Maxwell',    'Royal Challengers',   12, 'run out'),
    (7, 'MS Dhoni',         'Chennai Super Kings', 18, NULL),
    (8, 'Ruturaj Gaikwad',  'Chennai Super Kings', 58, 'caught'),
    (9, 'Shivam Dube',      'Chennai Super Kings', 40, 'bowled'),
    (10,'Rohit Sharma',     'Mumbai Indians',      40, 'caught');

SELECT COUNT(*) AS rows_loaded FROM deliveries;   -- expect 10

Step 2 — Core Logic

Now you write the core analytical queries using subqueries. A scalar subquery finds batsmen above the overall average, a correlated subquery finds those above their own team's average, and NOT EXISTS lists players who were never dismissed. Each targets a specific construct from the module, and you should predict the exact rows before running to confirm your understanding of how each behaves.

These queries show subqueries answering benchmark and presence questions directly. The scalar version computes one global figure, the correlated version recomputes per team, and the NOT EXISTS version safely handles the NULL dismissals where NOT IN could fail. Save them in ranking_lab.sql and verify that the per-team comparison yields different results than the global one.

Analogy🏏Cricket
🏏 Think of it like cricket: Think of the broadcast analyst slicing the same day's play three ways for three on-screen panels. Just as they break the data down by team for one graphic, by player for another, and by venue for a third, each query groups the same table on a different column. Just as every panel must rank its entries clearly, you order each by its key aggregate. Just as the panels share one underlying scorecard, your queries share one deliveries table. Just as a panel showing only three teams instantly warns the analyst the feed is incomplete, eyeballing each query's row counts against known facts catches bugs early. Just as the director insists each graphic answers exactly one question cleanly, each query should group on exactly the dimension its panel needs. This reveals why a single clean dataset can power many distinct summary views.
sql
-- ranking_lab.sql : run with  psql ipl_ranking -f ranking_lab.sql
-- Predict each result before running, then verify.

-- Q1. SCALAR subquery: batsmen above the overall average score.
SELECT batsman, team_name, runs_scored
FROM deliveries
WHERE runs_scored > (SELECT AVG(runs_scored) FROM deliveries)
ORDER BY runs_scored DESC, batsman ASC;

-- Q2. CORRELATED subquery: batsmen above THEIR OWN team's average.
SELECT d.batsman, d.team_name, d.runs_scored
FROM deliveries d
WHERE d.runs_scored > (
    SELECT AVG(d2.runs_scored)
    FROM deliveries d2
    WHERE d2.team_name = d.team_name        -- references outer row
)
ORDER BY d.team_name, d.runs_scored DESC;

-- Q3. NOT EXISTS: players who were NEVER dismissed (NULL-safe).
SELECT DISTINCT d.batsman
FROM deliveries d
WHERE NOT EXISTS (
    SELECT 1 FROM deliveries d2
    WHERE d2.batsman = d.batsman AND d2.dismissal IS NOT NULL
)
ORDER BY d.batsman;
-- A batsman dismissed in ANY row is excluded.

Step 3 — Integration & Enhancement

Step three assembles a ranked leaderboard with a chained CTE pipeline and adds head-to-head style comparison. You will build player_totals, then team_benchmarks, then a final ranked output that flags whether each player beat their team benchmark. This integrates aggregation, correlation logic, and CTEs into the kind of staged analysis a real leaderboard requires.

Watch the staging: each CTE has one clear responsibility and feeds the next, and the final query orders deterministically with a tie-breaker. Saving this alongside the core queries gives you a complete, reproducible ranking lab that demonstrates real command of composing subqueries and CTEs into readable multi-step analytics rather than an impenetrable nest.

Analogy🏏Cricket
🏏 Think of it like cricket: Picture the analyst adding the advanced graphics that follow the basic scoreboard. Just as they show only players who met a minimum-innings cut for an awards panel and chart scoring momentum month by month, you apply HAVING thresholds and DATE_TRUNC trends. Just as these refined panels demand more care than a raw total, they require correct filter placement and NULL handling. Just as the full package tells a richer story, your integrated queries deliver a complete dashboard. Just as the momentum chart would mislead viewers if rain-abandoned fixtures were charted as scoring dips, your trend query must decide explicitly how missing and NULL data appear. Just as the awards cut-off is printed on the graphic so viewers trust the ranking, the HAVING threshold belongs visibly in the query, not buried in app code. This shows why combining the module's tools yields professional-grade reporting.
sql
-- ranking_lab.sql (continued)

-- Q4. Chained CTE leaderboard: totals -> benchmarks -> ranked flags.
WITH player_totals AS (
    SELECT batsman, team_name,
           SUM(runs_scored) AS total_runs,
           COUNT(*)         AS innings
    FROM deliveries
    GROUP BY batsman, team_name
),
team_benchmarks AS (
    SELECT team_name,
           ROUND(AVG(total_runs),1) AS team_avg_total
    FROM player_totals
    GROUP BY team_name
)
SELECT pt.batsman,
       pt.team_name,
       pt.total_runs,
       tb.team_avg_total,
       CASE WHEN pt.total_runs > tb.team_avg_total
            THEN 'above' ELSE 'at/below' END AS vs_team
FROM player_totals AS pt
JOIN team_benchmarks AS tb ON pt.team_name = tb.team_name
ORDER BY pt.total_runs DESC, pt.batsman ASC;

-- Q5. Top scorer per team using a correlated subquery in WHERE.
SELECT d.batsman, d.team_name, d.runs_scored
FROM deliveries d
WHERE d.runs_scored = (
    SELECT MAX(d2.runs_scored)
    FROM deliveries d2
    WHERE d2.team_name = d.team_name
)
ORDER BY d.team_name;

-- Q6. Players whose single best innings beat the overall average,
--     expressed cleanly with a CTE instead of a nested subquery.
WITH overall AS (
    SELECT AVG(runs_scored) AS avg_runs FROM deliveries
)
SELECT d.batsman, MAX(d.runs_scored) AS best
FROM deliveries d, overall o
GROUP BY d.batsman, o.avg_runs
HAVING MAX(d.runs_scored) > o.avg_runs
ORDER BY best DESC, d.batsman ASC;

Step 4 — Testing & Verification

Finally, rebuild and run everything, then check the queries against the expected results below. Because all logic lives in scripts, you can regenerate the whole lab in seconds and confirm the benchmark, presence, and ranking queries return exactly the right rows, with the correlated and scalar versions correctly differing where the data makes them differ.

Analogy🏏Cricket
🏏 Think of it like cricket: Rebuilding the whole dashboard and checking each panel is like reprinting the entire season summary from the scorebook and confirming every graphic matches the record. Just as a broadcaster would not trust a figure that only appears if statements are run in a lucky order, you rebuild from scratch so no result depends on the sequence you happened to type. Just as the scorer reruns the totals and cross-checks the Orange Cap, run rates, and dismissal counts against the book, you check each panel against the expected results below. Just as the season book lets them regenerate every graphic in minutes, your scripted logic regenerates the entire dashboard data layer in seconds. Just as matching every panel to the record proves the summary is trustworthy, matching outputs confirms your figures are reproducible and correct. This reveals why a clean full rebuild is the real proof of a dashboard's integrity.
bash
# Rebuild and run end to end.
dropdb ipl_ranking && createdb ipl_ranking
psql ipl_ranking -f schema.sql
psql ipl_ranking -f ranking_lab.sql

# EXPECTED KEY RESULTS (verify a few):
# schema -> rows_loaded = 10
# Overall AVG runs = (72+61+28+45+33+12+18+58+40+40)/10 = 40.7
# Q1 (above overall avg 40.7): Rohit 72, Suryakumar 61, Ruturaj 58, Virat 45
#    (Faf 33, Shivam 40 are below/at; verify exact set)
# Q2 (above own team avg): differs per team; e.g. MI avg per innings
#    = (72+61+28+40)/4 = 50.25, so only Rohit 72 and Surya 61 qualify for MI.
# Q3 (never dismissed): players with NO dismissed row at all.
#    Rohit has a 'caught' row (id 10), so he is EXCLUDED. Faf (NULL) and
#    Dhoni (NULL) have no dismissed row -> included. Verify by running.
# Q5 (top per team): Rohit 72 (MI), Virat 45 (RCB), Ruturaj 58 (CSK).

# Spot-check overall average:
psql ipl_ranking -c "SELECT ROUND(AVG(runs_scored),1) FROM deliveries;"  # 40.7

Warning: In Q3, do not use batsman NOT IN (SELECT batsman FROM deliveries WHERE dismissal IS NOT NULL) as a shortcut. If any batsman value in that set were NULL, NOT IN would return zero rows, silently breaking the query. The NOT EXISTS form is NULL-safe and correctly excludes any batsman who was dismissed in even a single innings.

Extension Challenge: Add a Q7 using a recursive CTE to generate a rank ladder, or to produce a gap-free sequence of innings numbers, then LEFT JOIN your player_totals to it so missing ranks still appear. As a stretch, rewrite Q4's benchmark comparison so the team_benchmarks CTE is referenced twice, and confirm with EXPLAIN whether PostgreSQL inlines or materialises it.

  • Scalar subqueries compare every row to one global benchmark, while correlated subqueries recompute per group, giving different, context-aware shortlists.
  • NOT EXISTS expresses absence safely, excluding any player dismissed in even one innings, where a NOT IN against a nullable set could silently return nothing.
  • Chained CTEs stage a leaderboard as named steps, totals then benchmarks then ranked flags, turning multi-step analysis into a readable pipeline.
  • A correlated subquery in WHERE finds the top scorer per team by comparing each row to its own team's maximum, a clean per-group extreme pattern.
  • Composing aggregation, correlation, and CTEs answers sophisticated questions while keeping each stage independently testable and verifiable.
  • Predicting each query's rows before running, then verifying against expected results, builds trust in layered subquery and CTE logic.
Lesson 23 of 35
0% complete