DEV Community

Cover image for SQL Is Surviving, Franklin: Now Rows Are Competing
Faith Njenga
Faith Njenga

Posted on

SQL Is Surviving, Franklin: Now Rows Are Competing

If you have survived SELECT, WHERE, JOIN, GROUP BY, subqueries, and CTEs, congratulations.

Franklin has now discovered window functions.

And window functions are where SQL starts doing something that feels a little suspicious.

They can look at other rows without collapsing the rows you already have.

You can have:

  • An employee's salary and their department's average salary
  • A student's score and their rank
  • A month's sales and the running total
  • Today's sales and yesterday's sales

All in the same result.

Franklin has questions.

"Wait. SQL can look at other rows and still give me my original row?"

Yes, Franklin.

Welcome to window functions.

First, What Problem Are We Actually Solving?

Let's start with a tiny table.

employee department salary
Amina Data 80000
Brian Data 100000
Carol HR 70000
David HR 90000

Now imagine your manager asks:

"Show me every employee, their salary, and the average salary of their department."

You might think:

GROUP BY has entered the chat.

And you'd be right. Sort of.

We could calculate the department averages:

SELECT
    department,
    AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
Enter fullscreen mode Exit fullscreen mode

The result:

department avg_salary
Data 90000
HR 80000

Great.

Except...

Where did the employees go?

Amina disappeared.

Brian disappeared.

Carol disappeared.

David disappeared.

GROUP BY grouped the rows together to calculate one result per department.

But our question wanted both:

  1. The individual employee
  2. Information about the employee's group

This is where window functions walk in.

Meet OVER(): The Part That Makes the Window

Before we start throwing PARTITION BY, ORDER BY, and other SQL vocabulary at Franklin, let's understand the most important piece:

OVER()
Enter fullscreen mode Exit fullscreen mode

OVER() tells SQL:

"Perform this calculation across a set of rows, but keep the individual rows in the result."

For example:

SELECT
    employee,
    salary,
    AVG(salary) OVER () AS average_salary
FROM employees;
Enter fullscreen mode Exit fullscreen mode

The result could look like this:

employee salary average_salary
Amina 80000 85000
Brian 100000 85000
Carol 70000 85000
David 90000 85000

Let's break this down.

AVG(salary)
Enter fullscreen mode Exit fullscreen mode

means:

"Calculate the average salary."

Then:

OVER()
Enter fullscreen mode Exit fullscreen mode

means:

"Calculate it across the rows available to this query, but don't combine those rows into one."

That's the big difference.

Without a window function, an aggregate like AVG() can give us one overall result.

With AVG() OVER(), we can show the average alongside every individual row.

Think of OVER() as the doorway into window-function territory.

Once we step inside, we can tell SQL more specifically which rows it should consider.

PARTITION BY: Franklin, Pick Your Group

What if we don't want the average salary for everyone?

What if we want the average salary within each department?

That's where PARTITION BY comes in.

SELECT
    employee,
    department,
    salary,
    AVG(salary) OVER (
        PARTITION BY department
    ) AS department_avg
FROM employees;
Enter fullscreen mode Exit fullscreen mode

The result:

employee department salary department_avg
Amina Data 80000 90000
Brian Data 100000 90000
Carol HR 70000 80000
David HR 90000 80000

Let's break down the important part:

PARTITION BY department
Enter fullscreen mode Exit fullscreen mode

This tells SQL:

"Separate the rows into groups based on department, but don't collapse them."

So SQL can mentally create:

Data

employee salary
Amina 80000
Brian 100000

HR

employee salary
Carol 70000
David 90000

Then AVG() works within each group.

This is one of the most important differences to remember:

GROUP BY collapses rows into groups.

PARTITION BY creates groups for the window calculation while keeping the original rows.

Franklin can breathe again.

ORDER BY: Now Franklin Needs an Order

PARTITION BY answers:

"Which rows belong together?"

Sometimes we also need to answer:

"In what order should SQL consider these rows?"

That's where ORDER BY inside OVER() comes in.

For example, suppose we want to rank employees by salary.

SELECT
    employee,
    department,
    salary,
    RANK() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Here:

PARTITION BY department
Enter fullscreen mode Exit fullscreen mode

means:

"Rank employees separately within each department."

And:

ORDER BY salary DESC
Enter fullscreen mode Exit fullscreen mode

means:

"Put the highest salary first."

So OVER() is the container, while PARTITION BY and ORDER BY give SQL more instructions about the rows involved.

Ranking: Apparently, Everyone Needs a Position

Now let's look at three ranking functions.

We'll use this table:

employee salary
Amina 100000
Brian 100000
Carol 80000
David 70000

ROW_NUMBER(): Everyone Gets a Number

SELECT
    employee,
    salary,
    ROW_NUMBER() OVER (
        ORDER BY salary DESC
    ) AS row_number
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Result:

employee salary row_number
Amina 100000 1
Brian 100000 2
Carol 80000 3
David 70000 4

Notice something important.

Amina and Brian have the same salary, but they still receive different numbers.

ROW_NUMBER() gives every row a unique position.

Think:

"I don't care about ties. Just number every row."

RANK(): Ties Are Allowed

Now let's use RANK().

SELECT
    employee,
    salary,
    RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Result:

employee salary salary_rank
Amina 100000 1
Brian 100000 1
Carol 80000 3
David 70000 4

Amina and Brian both receive rank 1 because they have the same salary.

But notice what happened next.

There is no rank 2.

The next person gets rank 3.

Think:

"Ties get the same rank, and the next rank can have a gap."

DENSE_RANK(): Ties Without the Gap

Now:

SELECT
    employee,
    salary,
    DENSE_RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Result:

employee salary salary_rank
Amina 100000 1
Brian 100000 1
Carol 80000 2
David 70000 3

Amina and Brian still tie for first.

But this time, Carol gets rank 2.

No gap.

So Franklin's ranking dictionary is:

Function What it does
ROW_NUMBER() Gives every row a unique number
RANK() Ties get the same rank, and gaps can appear
DENSE_RANK() Ties get the same rank, but no gaps appear

Three functions.

Three slightly different personalities.

Choose based on what the question actually asks.

LAG() and LEAD(): Looking Behind and Ahead

Now suppose we have monthly sales:

month sales
January 100
February 200
March 300
April 400

And someone asks:

"How much did sales change compared with the previous month?"

We could try to join the table to itself.

Or we could use LAG().

SELECT
    month,
    sales,
    LAG(sales) OVER (
        ORDER BY month
    ) AS previous_month
FROM sales;
Enter fullscreen mode Exit fullscreen mode

Result:

month sales previous_month
January 100 NULL
February 200 100
March 300 200
April 400 300

LAG() looks backward.

For February, it finds January.

For March, it finds February.

For April, it finds March.

The first row has nothing before it, so SQL gives us NULL.

And LEAD() does the opposite.

LEAD(sales) OVER (
    ORDER BY month
)
Enter fullscreen mode Exit fullscreen mode

LEAD() looks at the row after the current row.

So:

  • LAG() → "What happened before me?"
  • LEAD() → "What happens after me?"

Franklin is now looking into the past and the future.

Running Totals: When SUM() Gets a Memory

Now someone asks:

"Show me the sales for each month and the total sales accumulated so far."

Our table:

month sales
January 100
February 200
March 300
April 400
May 500

We can write:

SELECT
    month,
    sales,
    SUM(sales) OVER (
        ORDER BY month
    ) AS running_total
FROM sales;
Enter fullscreen mode Exit fullscreen mode

And the result can be:

month sales running_total
January 100 100
February 200 300
March 300 600
April 400 1000
May 500 1500

But wait.

Franklin has a very reasonable question:

"Hold on. I only wrote SUM(sales) and ORDER BY month. How did SQL know that February should include January, and March should include January and February?"

Excellent question.

There is something happening behind the scenes.

When an aggregate window function such as SUM() has an ORDER BY but no frame explicitly specified, the database uses a default window frame.

For this simple example, that gives us the running-total behaviour we want.

Conceptually, SQL is looking at:

January
→ January

February
→ January + February

March
→ January + February + March

April
→ January + February + March + April

May
→ January + February + March + April + May
Enter fullscreen mode Exit fullscreen mode

So:

100
100 + 200 = 300
100 + 200 + 300 = 600
100 + 200 + 300 + 400 = 1000
100 + 200 + 300 + 400 + 500 = 1500
Enter fullscreen mode Exit fullscreen mode

Now we can make those boundaries explicit:

SELECT
    month,
    sales,
    SUM(sales) OVER (
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM sales;
Enter fullscreen mode Exit fullscreen mode

The result is:

month sales running_total
January 100 100
February 200 300
March 300 600
April 400 1000
May 500 1500

This version tells SQL exactly what we mean:

ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
Enter fullscreen mode Exit fullscreen mode

In human language:

"Start from the very first row and keep including rows until the row I'm currently calculating."

And now Franklin asks:

"Okay... what exactly are ROWS, BETWEEN, UNBOUNDED PRECEDING, and CURRENT ROW?"

Franklin.

You just opened the window frame.

Meet the Window Frame: Franklin, We Need Boundaries

A window is the set of rows that a window function can work with.

A window frame goes one step further.

It tells SQL:

"From that window, these are the exact rows I want you to use for this calculation."

This is where you might see:

  • CURRENT ROW
  • PRECEDING
  • FOLLOWING
  • UNBOUNDED PRECEDING
  • UNBOUNDED FOLLOWING
  • ROWS BETWEEN

They sound complicated.

They're really just instructions about where to look.

CURRENT ROW: Where Am I?

CURRENT ROW means:

"The row I'm currently calculating."

If SQL is calculating the result for March:

January
February
March     <- CURRENT ROW
April
May
Enter fullscreen mode Exit fullscreen mode

March is the current row.

Simple.

PRECEDING: Look Behind Me

PRECEDING means rows before the current row.

For example:

2 PRECEDING
Enter fullscreen mode Exit fullscreen mode

means:

"Go back two rows."

If we're on March:

January   <- 2 PRECEDING
February  <- 1 PRECEDING
March     <- CURRENT ROW
April
May
Enter fullscreen mode Exit fullscreen mode

FOLLOWING: Look Ahead

FOLLOWING means rows after the current row.

For example:

2 FOLLOWING
Enter fullscreen mode Exit fullscreen mode

means:

"Look two rows ahead."

If we're on March:

January
February
March     <- CURRENT ROW
April     <- 1 FOLLOWING
May       <- 2 FOLLOWING
Enter fullscreen mode Exit fullscreen mode

Franklin has now learned that SQL can look backward and forward.

ROWS BETWEEN: Tell SQL Exactly Where to Look

Suppose we want a 3-month moving average.

For each month, we want:

Current month + the two months before it.

We can write:

SELECT
    month,
    sales,
    AVG(sales) OVER (
        ORDER BY month
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg
FROM sales;
Enter fullscreen mode Exit fullscreen mode

Let's translate the important part.

ROWS BETWEEN
Enter fullscreen mode Exit fullscreen mode

means:

"Define the exact rows I want to use."

2 PRECEDING
Enter fullscreen mode Exit fullscreen mode

means:

"Start two rows before me."

CURRENT ROW
Enter fullscreen mode Exit fullscreen mode

means:

"Stop at the row I'm currently calculating."

So for March, the frame is:

January   <- 2 PRECEDING
February  <- 1 PRECEDING
March     <- CURRENT ROW
Enter fullscreen mode Exit fullscreen mode

Those three rows are used to calculate March's moving average.

The result:

month sales moving_avg
January 100 100
February 200 150
March 300 200
April 400 300
May 500 400

Notice that January doesn't have two previous rows.

SQL doesn't invent them.

It simply uses the rows that actually exist.

UNBOUNDED PRECEDING: Start From the Beginning

Now imagine we want a running total.

Instead of saying:

"Go back two rows."

We want:

"Go all the way back to the first row."

That's:

UNBOUNDED PRECEDING
Enter fullscreen mode Exit fullscreen mode

So we can write:

SELECT
    month,
    sales,
    SUM(sales) OVER (
        ORDER BY month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM sales;
Enter fullscreen mode Exit fullscreen mode

In plain English:

"Start at the very first row and keep going until the row I'm currently calculating."

So when we're calculating April:

January   <- start here
February
March
April     <- CURRENT ROW
May
Enter fullscreen mode Exit fullscreen mode

SQL adds January through April.

The result is:

month sales running_total
January 100 100
February 200 300
March 300 600
April 400 1000
May 500 1500

That's why the running total keeps growing.

UNBOUNDED FOLLOWING: Go All the Way to the End

There is also:

UNBOUNDED FOLLOWING
Enter fullscreen mode Exit fullscreen mode

This means:

"Keep going until the last row."

For example:

ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
Enter fullscreen mode Exit fullscreen mode

means:

"Start at my current row and continue all the way to the end."

If we're on March:

January
February
March     <- CURRENT ROW
April
May       <- UNBOUNDED FOLLOWING
Enter fullscreen mode Exit fullscreen mode

The frame is:

March + April + May
Enter fullscreen mode Exit fullscreen mode

You don't need to memorize every possible combination right away.

Start with the vocabulary.

Franklin's Window Frame Dictionary

SQL Human translation
CURRENT ROW The row I'm on
1 PRECEDING One row before me
2 PRECEDING Two rows before me
1 FOLLOWING One row after me
2 FOLLOWING Two rows after me
UNBOUNDED PRECEDING Start from the beginning
UNBOUNDED FOLLOWING Go all the way to the end

And:

ROWS BETWEEN A AND B
Enter fullscreen mode Exit fullscreen mode

basically means:

"Use the rows from A through B for this calculation."

That's the idea you want to carry with you.

WHERE Comes First: Sorry, Window Functions

Here's another rule that actually matters.

Suppose we calculate employee rankings:

SELECT
    employee,
    salary,
    RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees;
Enter fullscreen mode Exit fullscreen mode

Now Franklin says:

"Cool. Give me only employees ranked number 1."

Naturally, he tries:

SELECT
    employee,
    salary,
    RANK() OVER (
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees
WHERE salary_rank = 1;
Enter fullscreen mode Exit fullscreen mode

SQL says:

"Nope."

Why?

Because, in the simplified logical processing order, WHERE is handled before the window function result is available.

A simplified order looks like this:

FROM
↓
WHERE
↓
GROUP BY
↓
HAVING
↓
SELECT
↓
WINDOW FUNCTIONS
↓
ORDER BY
Enter fullscreen mode Exit fullscreen mode

So when SQL is evaluating WHERE, salary_rank isn't available yet.

The solution is to give SQL another query layer.

WITH ranked_employees AS (
    SELECT
        employee,
        salary,
        RANK() OVER (
            ORDER BY salary DESC
        ) AS salary_rank
    FROM employees
)

SELECT *
FROM ranked_employees
WHERE salary_rank = 1;
Enter fullscreen mode Exit fullscreen mode

The CTE calculates the rank first.

Then the outer query receives that result.

Now WHERE can filter it.

Franklin survives.

Again.

Can Window Functions Be Nested?

Franklin has gained confidence.

Perhaps too much confidence.

He tries this:

AVG(
    RANK() OVER (
        ORDER BY salary DESC
    )
) OVER ()
Enter fullscreen mode Exit fullscreen mode

No.

Window functions cannot be directly nested inside another window function.

If you need to use the result of one window calculation in another calculation, use another query layer.

For example:

WITH ranked AS (
    SELECT
        employee,
        salary,
        RANK() OVER (
            ORDER BY salary DESC
        ) AS salary_rank
    FROM employees
)

SELECT
    employee,
    salary,
    salary_rank
FROM ranked;
Enter fullscreen mode Exit fullscreen mode

Think of it this way:

One query layer calculates. Another query layer uses the result.

This is another place where CTEs and subqueries become useful.

Can Window Functions Work With GROUP BY?

Yes.

This is another common misconception.

Window functions and GROUP BY can absolutely appear in the same query.

Suppose we first calculate the average salary for each department:

SELECT
    department,
    AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
Enter fullscreen mode Exit fullscreen mode

Now we have one row per department.

We can rank those department averages:

SELECT
    department,
    AVG(salary) AS avg_salary,
    RANK() OVER (
        ORDER BY AVG(salary) DESC
    ) AS department_rank
FROM employees
GROUP BY department;
Enter fullscreen mode Exit fullscreen mode

The important idea is:

GROUP BY determines the grouped rows first, and the window function can then work with those resulting rows.

So don't memorize:

"Window functions and GROUP BY don't mix."

That's not true.

Instead, ask:

"What rows exist at the point where my window function is being calculated?"

That question will save you from a lot of SQL confusion.

Franklin's Window Function Survival Guide

Before Franklin walks away pretending he understood everything on the first try, let's recap.

Need to keep individual rows?

Think:

OVER()
Enter fullscreen mode Exit fullscreen mode

Need calculations within groups?

Think:

PARTITION BY
Enter fullscreen mode Exit fullscreen mode

Need the rows considered in a particular order?

Think:

ORDER BY
Enter fullscreen mode Exit fullscreen mode

Need rankings?

Think:

ROW_NUMBER()
RANK()
DENSE_RANK()
Enter fullscreen mode Exit fullscreen mode

Need the previous row?

Think:

LAG()
Enter fullscreen mode Exit fullscreen mode

Need the next row?

Think:

LEAD()
Enter fullscreen mode Exit fullscreen mode

Need a running total?

Think:

SUM() OVER (ORDER BY ...)
Enter fullscreen mode Exit fullscreen mode

Need a moving calculation?

Think:

ROWS BETWEEN ...
Enter fullscreen mode Exit fullscreen mode

Need to look backward?

Think:

PRECEDING
Enter fullscreen mode Exit fullscreen mode

Need to look forward?

Think:

FOLLOWING
Enter fullscreen mode Exit fullscreen mode

Need to start from the first row?

Think:

UNBOUNDED PRECEDING
Enter fullscreen mode Exit fullscreen mode

Need to go to the last row?

Think:

UNBOUNDED FOLLOWING
Enter fullscreen mode Exit fullscreen mode

Need to filter a window-function result?

Don't put it directly in WHERE.

Use a:

CTE or subquery
Enter fullscreen mode Exit fullscreen mode

Need one window function to use another window function's result?

Don't nest them directly.

Use another query layer.

So, What Exactly Did We Survive?

Window functions aren't really about doing "complicated calculations."

They're about answering questions that involve relationships between rows without throwing those rows away.

You can ask:

"Who is the highest-paid employee in each department?"

"How does this month's sales compare with last month's?"

"What's the running total so far?"

"What's the three-month moving average?"

"Where does each student rank in their class?"

"What was the previous transaction?"

"What comes next?"

And suddenly SQL isn't just asking:

"What rows do you want?"

It's asking:

"How should these rows understand each other?"

That's the magic of window functions.

Franklin came in thinking SQL only knew how to look at one row at a time.

He leaves knowing SQL can look across groups, backward, forward, and through carefully defined frames.

He just shouldn't try to put a window function inside another window function.

SQL has boundaries.

Even Franklin needs some.

What's Next?

We've survived:

  • SELECT
  • Filtering
  • GROUP BY
  • Joins
  • Subqueries
  • CTEs
  • Window functions

And somehow, Franklin is still here.

So apparently, surviving SQL was never the finish line.

It was just the beginning.

Top comments (0)