◀ Course contents Part 4 · Module 4-01

Window Functions

Summarising rows without destroying them

The first module of Part 4, and the one that separates people who can query a database from people who can analyse one. GROUP BY answers "what is the total" by throwing the detail away. A window function answers it and keeps every row — so each row can be compared against its own group, ranked within it, and shown as a share of it.

Ready?

1

GROUP BY Collapses. OVER Does Not.

Everything in Part 2 came at a price. GROUP BY gives you the total, and in exchange it destroys the rows it summarised. Twenty-four orders go in, ten country rows come out, and no individual order survives to be looked at.

Most analyst questions want both at once. "Show me each order and how it compares to the average." "Rank these customers." "What share of revenue is this?" Every one of those needs the detail and a summary of it on the same row.

24 rows in, 10 out

GROUP BY

SELECT country, SUM(amount)
FROM orders JOIN users …
GROUP BY country;

One row per country. Which orders made up that total is no longer answerable.

24 rows in, 24 out

OVER ()

SELECT order_id, amount,
       SUM(amount) OVER ()
FROM orders;

Every order, each carrying the grand total beside it.

The brackets after OVER are the window: which rows this calculation is allowed to look at. Empty brackets mean all of them. That is the entire concept — a second pass over the same rows, reported alongside the first.

A window around an aggregate

The share-of-total idiom looks like a typo the first time you meet it, and it is worth reading slowly:

SELECT u.country,
       SUM(o.amount)              AS revenue,
       SUM(SUM(o.amount)) OVER () AS all_revenue
FROM orders o JOIN users u ON u.user_id = o.user_id
GROUP BY u.country;

The inner SUM is the ordinary aggregate, computed per country. The outer one is a window over the rows GROUP BY has already produced — nine country rows — and totals those. Grouping happens first; the window sees its output. Divide one by the other and you have a percentage in a single pass.

Quick check

SELECT order_id, SUM(amount) OVER () FROM orders against a 24-row table returns how many rows?

2

Narrowing the Window

OVER () looked at every row. PARTITION BY narrows the window to rows sharing a value — so each row is measured against its own group rather than the whole table.

AVG(o.amount) OVER (PARTITION BY u.country)

Read it as "the average, restarted for each country". Every order still appears; each now carries the average of the country it belongs to.

This is the correlated subquery from 3-05

Module 3-05 answered "orders above their own country's average" with a correlated subquery that re-ran once per row. This is the same question, computed in one pass instead of twenty-four, and readable on one line. The correlated form is still worth knowing — some engines lack windows — but on Postgres this is the answer.

PARTITION BY is not GROUP BY, and the difference is worth being precise about: GROUP BY decides how many rows come back, PARTITION BY decides what each row's calculation can see. They can appear in the same query and mean different things, which is how the share-of-total idiom works.

Two ORDER BYs

Once a window has an ORDER BY inside it, there are two in the query and they do unrelated jobs:

IN

Inside OVER

Decides what gets number 1 — the order the window function counts in.

OUT

At the end of the query

Decides the order rows are printed in. Frequently the same; never required to be.

Quick check

You replace OVER (PARTITION BY u.country) with OVER () in an "orders above their country average" query. What happens?

3

Three Ranking Functions That Only Differ on Ties

With no ties, RANK, DENSE_RANK and ROW_NUMBER return identical columns. That is precisely why the difference between them tends to be learned from a bug report.

In this database five users have 3 orders each and four have 2, so the disagreement is visible:

users ranked by order count, descending
nameordersRANKDENSE_RANKROW_NUMBER
Ada Lovelace3111
Grace Hopper3112
Mei Lin3113
Nia Mwangi3114
Priya Nair3115
Kenji Tanaka2626
Rosa Diaz2627

The shaded row is where they part company. RANK skips to 6 because five people are ahead. DENSE_RANK says 2 because it ranks values, not rows. ROW_NUMBER never ties at all.

RANK

Ties share, then it skips

Competition ranking — three joint silvers and no bronze. Use when the position should reflect how many are genuinely ahead.

DENSE

Ties share, no gap

Ranks distinct values. Use for "which price tier is this", where the count of rows above is irrelevant.

ROW

Always 1, 2, 3

Use when you need exactly one row per position — deduplicating, or picking a single winner per group.

ROW_NUMBER without a tiebreaker is not reproducible

Among equal rows, ROW_NUMBER picks an order the database finds convenient, and it may differ between runs or after an index change. If the numbering matters, add a second sort key that is unique — ORDER BY amount DESC, order_id. A "top 10" that quietly reshuffles is a bug nobody can reproduce.

Choosing wrongly is not a style error. "Who is in the top 3?" answered with ROW_NUMBER silently cuts off two of five equal leaders, and the report will not mention that it did.

NTILE(n) belongs to the same family: it splits the window into n equal-sized buckets for quartiles and deciles. It divides by position, not value, so equal values can land in different buckets — its job is to keep the buckets the same size.

Quick check

Four salespeople tie for first place. What does the fifth get from RANK and from DENSE_RANK?

4

The Rule That Catches Everyone

The obvious next move is to filter on the number you just computed. It does not work, and Postgres is blunt about why:

WHERE ROW_NUMBER() OVER (ORDER BY amount DESC) <= 3

ERROR:  window functions are not allowed in WHERE

This is a consequence of ordering rather than an arbitrary restriction. Window functions are evaluated after WHERE, GROUP BY and HAVING — they run on whatever survived them. At the moment WHERE is deciding, no window has been computed yet. The same error appears for GROUP BY and HAVING.

One useful consequence: a WHERE in the same query filters the rows the window then sees. Restrict to paid orders and every running total and average is over paid orders only — usually exactly what you wanted.

Two passes, and the pattern it unlocks

To filter on a window value, compute it in a CTE and filter outside. That two-step is the single most useful shape in Part 4: the top N rows within each group.

WITH ranked AS (
  SELECT u.country, o.order_id, o.amount,
         ROW_NUMBER() OVER (PARTITION BY u.country
                            ORDER BY o.amount DESC, o.order_id) AS rn
  FROM orders o JOIN users u ON u.user_id = o.user_id
  WHERE o.status = 'paid'
)
SELECT country, order_id, amount
FROM ranked
WHERE rn = 1;

"The biggest order in each country" resists everything before this module. MAX(amount) gives the number but not the order it belongs to; grouping and joining back is fiddly and duplicates on ties. Number within the partition, then keep number one.

= 1

One winner per group

Change to <= 3 for a top three. The pattern does not otherwise change.

ROW

ROW_NUMBER for exactly one

A tie is broken by your tiebreaker, and you get one row per group whatever happens.

RANK

RANK to keep genuine ties

Two orders tied at the top both survive. That is the whole decision — and it changes the row count.

Next: frames

Every window here has looked at a whole partition at once. Module 4-02 adds the frame — the ability to look at rows up to this one, or the row before, or a trailing seven days — which is what running totals, month-on-month change and moving averages are made of.

Quick check

You need the two most recent orders per customer. Which shape?

0 of 9 completed

Loading the tables…

01

To do

GROUP BY answers "what is the total" by destroying the detail. Twenty-four orders go in, ten country rows come out, and no individual order survives to be looked at.

A window function computes the same kind of summary without collapsing anything. Add OVER () to an aggregate and every row stays, each one now carrying the total alongside its own values.

SELECT order_id, amount,
SUM(amount) OVER () AS all_orders
FROM orders; -- 24 rows, every one showing the same grand total

The empty brackets are the window: which rows this calculation may look at. Empty means "all of them". That is the whole idea — a second pass over the same rows, reported next to the first.

Your task: return order_id, amount, and the total of all order amounts as all_orders, for every order, ordered by order_id.

query.sql
PostgreSQL
Hint

SUM(o.amount) OVER () AS all_orders. The brackets after OVER stay empty — you want every row in the window.

Output

      
    02

    To do

    "What percentage of revenue is each country?" needs two numbers at different levels at once: the country's revenue, and everyone's. Before windows that meant computing the total separately and joining it back.

    A window can wrap an aggregate. It looks wrong the first time:

    SELECT u.country,
    SUM(o.amount) AS revenue,
    SUM(SUM(o.amount)) OVER () AS all_revenue
    FROM orders o JOIN users u ON u.user_id = o.user_id
    GROUP BY u.country;

    The inner SUM is the ordinary aggregate, computed per country. The outer one is a window over the grouped rows — the GROUP BY has already happened, so the window sees nine country rows and totals those. It is not a typo, and it is the standard way to write a share.

    Your task: for paid orders, return each country, its revenue, and its pct of total revenue rounded to one decimal place. Order by revenue descending, then country.

    query.sql
    PostgreSQL
    Hint

    The denominator is SUM(SUM(o.amount)) OVER () — a window over the already-grouped rows. The 100.0 keeps it out of integer division, which was module 2-05.

    Output
    
          
      03

      To do

      OVER () looked at everything. PARTITION BY narrows the window to rows sharing a value — so each row is compared against its own group rather than the whole table.

      AVG(o.amount) OVER (PARTITION BY u.country)

      Read it as "the average, restarted for each country". Every order still appears; each now carries the average for the country it belongs to.

      This is the same question the correlated subquery answered in module 3-05 — compare a row against an aggregate of its own group — written in one line and computed in one pass rather than re-running an inner query per row.

      Your task: for paid orders, return order_id, country, amount, and that country's average order value rounded to two decimals as country_avg. Order by country, then order_id.

      query.sql
      PostgreSQL
      Hint

      AVG(o.amount) OVER (PARTITION BY u.country) goes inside the ROUND. There is no GROUP BY — the rows are not being collapsed.

      Output
      
            
        04

        To do

        ROW_NUMBER() hands each row a position: 1, 2, 3. It takes no argument, and it needs an ORDER BY inside the OVER to know what order it is numbering.

        ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC)

        Two different ORDER BYs are now in play and they do different jobs. The one inside OVER decides what gets number 1. The one at the end of the query decides what order the rows are printed in. They are frequently the same and do not have to be.

        Your task: for paid orders, return country, order_id, amount, and a rn numbering that country's orders from most to least valuable. Break amount ties with the lower order_id first. Print ordered by country, then rn.

        query.sql
        PostgreSQL
        Hint

        ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC, o.order_id) AS rn — the second sort key is the tiebreaker, without which the numbering is not reproducible.

        Output
        
              
          05

          To do

          With no ties the three ranking functions are identical, which is why the difference between them is usually learned the hard way. Here there are real ties: five users have 3 orders each, and four have 2.

          RANK

          Ties share, then it skips

          1, 1, 1, 1, 1, 6 — five joint firsts, and the next is sixth. Competition ranking.

          DENSE

          Ties share, no gap

          1, 1, 1, 1, 1, 2 — the next distinct value is second. Ranks the values, not the rows.

          ROW

          No ties, ever

          1, 2, 3, 4, 5, 6 — arbitrary among equals unless you add a tiebreaker.

          Which one is right depends on the question. "Who is in the top 3?" with ROW_NUMBER silently cuts off two of five equal leaders and the report will not mention it.

          Your task: return every user's name, their orders count, and all three rankings by order count descending, as rnk, dense and rn. Break the ROW_NUMBER tie by name. Order by orders descending, then name.

          query.sql
          PostgreSQL
          Hint

          All three take the aggregate directly: RANK() OVER (ORDER BY COUNT(o.order_id) DESC), the same for DENSE_RANK(), and ROW_NUMBER() OVER (ORDER BY COUNT(o.order_id) DESC, u.name).

          Output
          
                
            06

            To do

            The rule that catches everyone, and Postgres is blunt about it:

            WHERE ROW_NUMBER() OVER (ORDER BY o.order_id) <= 3
            ERROR: window functions are not allowed in WHERE

            Not a limitation so much as a consequence of ordering. Window functions are evaluated after WHERE, GROUP BY and HAVING — they run on whatever survived those. So at the moment WHERE is deciding, no window has been computed yet. The same error appears for GROUP BY and HAVING.

            It does mean a WHERE in the same query filters the rows the window then sees. Filter to paid orders and the running totals and averages are over paid orders only, which is usually what you wanted.

            To filter on a window value, compute it in a CTE and filter outside — a second pass, and the standard shape.

            Your task: return the order_id and amount of the three largest paid orders, with their rn, using a CTE and ROW_NUMBER. Break ties by the lower order_id. Order by rn.

            query.sql
            PostgreSQL
            Hint

            Inside the CTE: SELECT o.order_id, o.amount, ROW_NUMBER() OVER (ORDER BY o.amount DESC, o.order_id) AS rn FROM orders o WHERE o.status = 'paid'. Then filter WHERE rn <= 3 outside it.

            Output
            
                  
              07

              To do

              Now the pattern this all builds to, and probably the single most useful thing in Part 4: the top N rows within each group.

              "The biggest order in each country" resists everything so far. MAX(amount) gives the number but not the order it belongs to. Grouping and then joining back is possible and fiddly, and duplicates when two orders tie.

              Number within the partition, then keep number one:

              WITH ranked AS (
              SELECT …, ROW_NUMBER() OVER (PARTITION BY u.country
              ORDER BY o.amount DESC, o.order_id) AS rn
              FROM …
              )
              SELECT … FROM ranked WHERE rn = 1;

              Change = 1 to <= 3 for a top three. Use RANK() instead if genuine ties should all be kept — that is the whole decision.

              Your task: return country, order_id and amount for each country's largest paid order, breaking ties by the lower order_id, ordered by country.

              query.sql
              PostgreSQL
              Hint

              In the CTE: u.country, o.order_id, o.amount and ROW_NUMBER() OVER (PARTITION BY u.country ORDER BY o.amount DESC, o.order_id) AS rn, from orders joined to users where status = 'paid'. Then WHERE rn = 1.

              Output
              
                    
                08

                To do

                NTILE(n) splits the window into n buckets of as-equal-as-possible size and labels each row with its bucket number. It is how quartiles, deciles and "top 20% of customers" get built.

                NTILE(4) OVER (ORDER BY o.amount DESC)   -- 1 = the biggest quarter

                It divides by position, not by value. Rows with equal values can land in different buckets, because NTILE's job is to make the buckets the same size. If the boundary must respect ties, that is RANK or an explicit range, not NTILE.

                When the count does not divide evenly the earlier buckets get the extra row each — 21 orders into 4 buckets gives 6, 5, 5, 5.

                Your task: for paid orders, return order_id, amount and a quartile from 1 (largest) to 4, breaking ties by the lower order_id. Order by amount descending, then order_id.

                query.sql
                PostgreSQL
                Hint

                NTILE(4) OVER (ORDER BY o.amount DESC, o.order_id) AS quartile.

                Output
                
                      
                  09

                  To do

                  Module 3-05 asked for every order worth more than the average order in its own country, and answered it with a correlated subquery that re-ran once per row.

                  A window computes every country's average in one pass. The only wrinkle is the rule from two stations ago — the comparison cannot live in WHERE, so the window goes in a CTE and the filter goes outside it.

                  Both are correct. The window version is usually faster, and it is certainly easier to read once the shape is familiar. The correlated form is worth keeping in hand for engines that do not support windows, which by now are few.

                  Your task: return order_id, country and amount for every order worth more than the average order in its own country — using a window rather than a correlated subquery. Order by order_id.

                  query.sql
                  PostgreSQL
                  Hint

                  In the CTE, add AVG(o.amount) OVER (PARTITION BY u.country) AS country_avg beside the columns, over orders joined to users with no status filter. Then WHERE amount > country_avg.

                  Output
                  
                        

                    The customer leaderboard

                    To do

                    A leaderboard is the natural home of a window function: every customer's own number, their position against everyone else, and their share of the whole — three levels on one row, which is exactly what OVER is for.

                    Your task: one row for every user, with:

                    • name — the user's name
                    • country — their country
                    • orders — how many paid orders they have, 0 if none
                    • spent — their total paid amount, 0 rather than NULL if none
                    • rank — their position by spent, highest first, with ties sharing a position
                    • pct_of_total — their share of all paid revenue, rounded to one decimal place

                    Order by spent descending, then name.

                    Everything from Part 3 is still in force: three users have no paid orders and must survive as zeros, so the join is an outer one with the paid condition in the ON. Those three tie on 0, and the ranking you were asked for is the one where a tie is shared rather than broken arbitrarily.

                    query.sql
                    PostgreSQL
                    Hint

                    The CTE is SELECT u.user_id, u.name, u.country, COUNT(o.order_id) AS orders, COALESCE(SUM(o.amount), 0) AS spent FROM users u LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid' GROUP BY u.user_id, u.name, u.country. Then RANK() OVER (ORDER BY spent DESC) and ROUND(100.0 * spent / SUM(spent) OVER (), 1).

                    Output
                    
                          

                      Notification