Loading content...
When you write OVER (ORDER BY sale_date), you're doing far more than sorting output—you're fundamentally changing how the window function calculates its result.
The ORDER BY clause within OVER serves three critical purposes:
Understanding ORDER BY in window functions is essential because it behaves differently than the ORDER BY at the end of your query. The outer ORDER BY determines output sequence; ORDER BY in OVER determines calculation logic.
By the end of this page, you will understand how ORDER BY in OVER defines calculation order, why it's mandatory for certain functions, how it changes default frame behavior, how to handle ties and NULLs, and how to use multiple ORDER BY columns effectively.
The ORDER BY clause within OVER specifies the logical ordering of rows within each partition (or within the entire result set if no PARTITION BY is present).
Syntax:
OVER (
[PARTITION BY ...]
ORDER BY column1 [ASC|DESC] [NULLS FIRST|LAST],
column2 [ASC|DESC] [NULLS FIRST|LAST],
...
)
Key points:
12345678910111213141516171819202122
-- Key distinction: OVER ORDER BY vs outer ORDER BYSELECT name, hire_date, salary, -- OVER ORDER BY: determines how ROW_NUMBER is calculated ROW_NUMBER() OVER (ORDER BY salary DESC) as salary_rankFROM employees-- Outer ORDER BY: determines output row sequenceORDER BY name ASC; -- Result:-- | name | hire_date | salary | salary_rank |-- |--------|------------|--------|-------------|-- | Alice | 2022-03-15 | 90000 | 1 | -- Output ordered by name-- | Bob | 2023-01-10 | 85000 | 2 |-- | Carol | 2021-06-01 | 70000 | 4 |-- | David | 2020-11-20 | 75000 | 3 | -- salary_rank is based on salary DESC (highest = 1)-- Output order is name ASC (alphabetical)-- These are completely independent orderingsThe ORDER BY inside OVER controls CALCULATION order. The ORDER BY at the end of the query controls OUTPUT order. They serve different purposes and are processed at different stages of query execution.
While ORDER BY is technically optional for aggregate window functions, several window-only functions require it for meaningful results. Without ORDER BY, these functions have no defined row sequence to work with.
| Category | Functions | ORDER BY Required? | Why? |
|---|---|---|---|
| Ranking | ROW_NUMBER(), RANK(), DENSE_RANK(), NTILE() | Logically required | Ranks need defined order to assign positions |
| Offset | LAG(), LEAD() | Logically required | 'Previous' and 'next' need defined sequence |
| Value | FIRST_VALUE(), LAST_VALUE(), NTH_VALUE() | Logically required | 'First', 'last', and 'Nth' need defined order |
| Distribution | PERCENT_RANK(), CUME_DIST() | Logically required | Percentile calculations need ordered values |
| Aggregate | SUM(), AVG(), COUNT(), MIN(), MAX() | Optional | Affects frame default; enables running totals |
123456789101112131415161718192021222324252627282930313233343536
-- Ranking functions NEED ORDER BY to be meaningful-- Without it, the database picks an arbitrary order SELECT name, salary, -- BAD: ORDER BY omitted - arbitrary ranking ROW_NUMBER() OVER () as arbitrary_rank, -- GOOD: ORDER BY specified - meaningful ranking ROW_NUMBER() OVER (ORDER BY salary DESC) as salary_rankFROM employees; -- The arbitrary_rank values are unpredictable and may change between executions -- LAG/LEAD need ORDER BY to define "previous" and "next"SELECT name, hire_date, salary, -- BAD: What does "previous" mean without order? LAG(salary) OVER () as arbitrary_prev, -- GOOD: "Previous by hire date" LAG(salary) OVER (ORDER BY hire_date) as prev_by_date, -- ALSO GOOD: "Previous by salary rank" LAG(salary) OVER (ORDER BY salary DESC) as prev_by_salaryFROM employees; -- FIRST_VALUE/LAST_VALUE need ORDER BYSELECT month, revenue, -- "First month's revenue for the year" FIRST_VALUE(revenue) OVER (ORDER BY month) as first_month_rev, -- "Highest revenue month" (first after DESC sort) FIRST_VALUE(revenue) OVER (ORDER BY revenue DESC) as highest_revFROM monthly_revenue;Most databases won't throw an error if you omit ORDER BY from ROW_NUMBER() or LAG(), but the results will be meaningless or non-deterministic. Always include ORDER BY for ranking, offset, and value functions.
One of the most critical—and often surprising—effects of ORDER BY in OVER is how it changes the default window frame for aggregate functions.
The rule:
This distinction explains why SUM(amount) OVER () gives the same total for every row, but SUM(amount) OVER (ORDER BY date) gives a running total.
12345678910111213141516171819202122232425262728293031323334353637
-- Sample data:-- | date | amount |-- |------------|--------|-- | 2024-01-01 | 100 |-- | 2024-01-02 | 200 |-- | 2024-01-03 | 150 |-- | 2024-01-04 | 300 | -- WITHOUT ORDER BY: all rows in partitionSELECT date, amount, SUM(amount) OVER () as total -- Same for all rowsFROM transactions; -- Result:-- | date | amount | total |-- |------------|--------|-------|-- | 2024-01-01 | 100 | 750 | -- Total of ALL rows-- | 2024-01-02 | 200 | 750 |-- | 2024-01-03 | 150 | 750 |-- | 2024-01-04 | 300 | 750 | -- WITH ORDER BY: start to current row (running total)SELECT date, amount, SUM(amount) OVER (ORDER BY date) as running_totalFROM transactions; -- Result:-- | date | amount | running_total |-- |------------|--------|---------------|-- | 2024-01-01 | 100 | 100 | -- Just row 1-- | 2024-01-02 | 200 | 300 | -- Rows 1+2-- | 2024-01-03 | 150 | 450 | -- Rows 1+2+3-- | 2024-01-04 | 300 | 750 | -- All rowsMany developers are surprised when adding ORDER BY to an aggregate window function changes the results from 'same value for all rows' to 'running total'. This is by design and is one of the most useful features of window functions, but you must be aware of it.
1234567891011121314151617181920212223
-- If you want ORDER BY but NOT a running total, specify frame explicitlySELECT date, amount, -- Running total (default frame with ORDER BY) SUM(amount) OVER (ORDER BY date) as running_total, -- Full partition total (explicit frame overrides default) SUM(amount) OVER ( ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) as totalFROM transactions; -- Result:-- | date | amount | running_total | total |-- |------------|--------|---------------|-------|-- | 2024-01-01 | 100 | 100 | 750 |-- | 2024-01-02 | 200 | 300 | 750 |-- | 2024-01-03 | 150 | 450 | 750 |-- | 2024-01-04 | 300 | 750 | 750 | -- The explicit frame gives same total for all rows, -- even though ORDER BY is specifiedWhen you specify multiple columns in ORDER BY, they work hierarchically:
This is essential for creating deterministic rankings when primary sort keys have duplicates.
1234567891011121314151617181920212223242526272829303132
-- Problem: Salary ties cause non-deterministic row numbers-- | name | salary |-- |--------|--------|-- | Alice | 90000 |-- | Bob | 85000 |-- | Carol | 85000 | -- Tied with Bob-- | David | 80000 | -- Single column: non-deterministic for tiesSELECT name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) as rankFROM employees; -- Result: Either Bob=2, Carol=3 OR Carol=2, Bob=3-- The database picks arbitrarily! -- Multiple columns: deterministic tiebreakerSELECT name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC, name ASC) as rankFROM employees; -- Result (always the same):-- | name | salary | rank |-- |-------|--------|------|-- | Alice | 90000 | 1 |-- | Bob | 85000 | 2 | -- Bob before Carol (B < C alphabetically)-- | Carol | 85000 | 3 |-- | David | 80000 | 4 |Different directions per column:
Each column can have its own ASC/DESC direction:
12345678910111213141516171819202122
-- Rank by salary descending, but for ties, by tenure ascending-- (highest paid first, and for same salary, newest employees first)SELECT name, salary, hire_date, ROW_NUMBER() OVER ( ORDER BY salary DESC, -- Highest salary first hire_date DESC -- Most recent hire first (for ties) ) as rankFROM employees; -- Another example: Department A-Z, then salary high-to-low within eachSELECT name, department, salary, ROW_NUMBER() OVER ( ORDER BY department ASC, salary DESC ) as org_chart_orderFROM employees;For deterministic results, always include enough ORDER BY columns to eliminate ties. If your primary sort key can have duplicates, add a secondary key. In the worst case, include a unique identifier (like primary key or row ID) as the final tiebreaker.
NULL values in ORDER BY columns require special consideration. Different databases have different defaults for where NULLs appear in the sort order, and the SQL standard provides explicit control through NULLS FIRST and NULLS LAST.
Default NULL ordering by database:
| Database | ASC Default | DESC Default |
|---|---|---|
| PostgreSQL | NULLS LAST | NULLS FIRST |
| Oracle | NULLS LAST | NULLS FIRST |
| SQL Server | NULLS FIRST (smallest) | NULLS LAST (smallest) |
| MySQL | NULLS FIRST (smallest) | NULLS LAST (smallest) |
| SQLite | NULLS FIRST (smallest) | NULLS LAST (smallest) |
12345678910111213141516171819202122232425262728293031323334353637383940
-- Data with NULL values:-- | name | bonus |-- |--------|--------|-- | Alice | 5000 |-- | Bob | NULL |-- | Carol | 3000 |-- | David | NULL |-- | Eve | 7000 | -- Default behavior (PostgreSQL: NULLS LAST for ASC)SELECT name, bonus, ROW_NUMBER() OVER (ORDER BY bonus ASC) as rankFROM employees; -- Result (PostgreSQL):-- | name | bonus | rank |-- |-------|-------|------|-- | Carol | 3000 | 1 |-- | Alice | 5000 | 2 |-- | Eve | 7000 | 3 |-- | Bob | NULL | 4 | -- NULLs sorted last-- | David | NULL | 5 | -- Explicit NULLS FIRSTSELECT name, bonus, ROW_NUMBER() OVER (ORDER BY bonus ASC NULLS FIRST) as rankFROM employees; -- Result:-- | name | bonus | rank |-- |-------|-------|------|-- | Bob | NULL | 1 | -- NULLs now first-- | David | NULL | 2 |-- | Carol | 3000 | 3 |-- | Alice | 5000 | 4 |-- | Eve | 7000 | 5 |Explicit NULL handling is recommended for portable code:
1234567891011121314151617181920212223242526
-- Explicit control ensures consistent behavior across databasesSELECT name, completion_date, RANK() OVER ( ORDER BY completion_date ASC NULLS LAST -- Incomplete items rank last ) as completion_orderFROM tasks; -- For descending sorts, you often want NULLS LAST as wellSELECT name, score, RANK() OVER ( ORDER BY score DESC NULLS LAST -- No score = bottom of rankings ) as leaderboard_rankFROM players; -- NULLS FIRST for DESC when NULLs represent "not yet evaluated"SELECT application_id, review_score, ROW_NUMBER() OVER ( ORDER BY review_score DESC NULLS FIRST -- Unreviewed first (priority) ) as review_queueFROM applications;SQL Server doesn't support NULLS FIRST/LAST syntax directly. Use CASE expressions as a workaround: ORDER BY CASE WHEN column IS NULL THEN 0 ELSE 1 END, column
When ORDER BY doesn't uniquely order rows—because multiple rows have the same value(s) in the ORDER BY column(s)—those rows are called peers. How window functions handle peers varies by function and frame type.
Key behaviors:
1234567891011121314151617181920212223242526272829
-- Data with ties:-- | name | score |-- |--------|-------|-- | Alice | 100 |-- | Bob | 95 |-- | Carol | 95 | -- Tied with Bob-- | David | 90 |-- | Eve | 90 | -- Tied with David-- | Frank | 85 | -- Comparing ranking functions for tiesSELECT name, score, ROW_NUMBER() OVER (ORDER BY score DESC) as row_num, -- Unique, arbitrary for peers RANK() OVER (ORDER BY score DESC) as rank, -- Same for peers, gaps DENSE_RANK() OVER (ORDER BY score DESC) as dense_rank -- Same for peers, no gapsFROM studentsORDER BY score DESC; -- Result:-- | name | score | row_num | rank | dense_rank |-- |-------|-------|---------|------|------------|-- | Alice | 100 | 1 | 1 | 1 |-- | Bob | 95 | 2 | 2 | 2 | -- Bob and Carol: rank 2-- | Carol | 95 | 3 | 2 | 2 | -- (row_num differs)-- | David | 90 | 4 | 4 | 3 | -- RANK skips 3, DENSE_RANK doesn't-- | Eve | 90 | 5 | 4 | 3 |-- | Frank | 85 | 6 | 6 | 4 |RANGE vs ROWS frame with peers:
The default frame when ORDER BY is specified is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. With RANGE, all peers are included together:
12345678910111213141516171819202122232425262728293031323334353637383940
-- Data with ties in ORDER BY column:-- | date | amount |-- |------------|--------|-- | 2024-01-01 | 100 |-- | 2024-01-02 | 200 |-- | 2024-01-02 | 150 | -- Same date (peers)-- | 2024-01-03 | 300 | -- Default frame (RANGE): includes all peers togetherSELECT date, amount, SUM(amount) OVER (ORDER BY date) as range_sum -- Default is RANGEFROM transactions; -- Result:-- | date | amount | range_sum |-- |------------|--------|-----------|-- | 2024-01-01 | 100 | 100 |-- | 2024-01-02 | 200 | 450 | -- Includes BOTH Jan 2 rows (200+150+100)-- | 2024-01-02 | 150 | 450 | -- Same! Both peers see all Jan 2 data-- | 2024-01-03 | 300 | 750 | -- Explicit ROWS frame: physical row order, not peersSELECT date, amount, SUM(amount) OVER ( ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) as rows_sumFROM transactions; -- Result:-- | date | amount | rows_sum |-- |------------|--------|----------|-- | 2024-01-01 | 100 | 100 |-- | 2024-01-02 | 200 | 300 | -- Different from next row!-- | 2024-01-02 | 150 | 450 | -- Depends on physical row position-- | 2024-01-03 | 300 | 750 |The default RANGE frame includes ALL peers up to and including the current row. This means rows with the same ORDER BY value get the same aggregate result. If you want strictly physical row-by-row running totals, use explicit ROWS frame.
Just like PARTITION BY, ORDER BY can use expressions instead of simple column references. This enables powerful patterns like ordering by computed values, conditional sorting, and derived metrics.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
-- Order by computed ageSELECT name, birth_date, ROW_NUMBER() OVER ( ORDER BY EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM birth_date) DESC ) as age_rankFROM employees; -- Order by absolute value (distance from zero)SELECT account_id, balance, RANK() OVER (ORDER BY ABS(balance) DESC) as magnitude_rankFROM accounts; -- Order by derived category, then by valueSELECT name, salary, ROW_NUMBER() OVER ( ORDER BY CASE WHEN salary >= 100000 THEN 1 WHEN salary >= 70000 THEN 2 ELSE 3 END, salary DESC ) as tiered_rankFROM employees; -- Order by calculated fiscal year (April start)SELECT transaction_date, amount, SUM(amount) OVER ( ORDER BY CASE WHEN EXTRACT(MONTH FROM transaction_date) >= 4 THEN EXTRACT(YEAR FROM transaction_date) ELSE EXTRACT(YEAR FROM transaction_date) - 1 END, transaction_date ) as fiscal_ytdFROM transactions;Ordering by aggregates requires subquery:
You cannot ORDER BY an aggregate in the same window function (circular reference). Use a subquery or CTE:
123456789101112131415161718192021222324252627
-- WRONG: Circular reference-- SELECT name, SUM(sales), -- RANK() OVER (ORDER BY SUM(sales) DESC) -- Can't aggregate in ORDER BY-- FROM salespeople; -- CORRECT: Aggregate first, then rankWITH aggregated AS ( SELECT name, SUM(sales) as total_sales FROM salespeople GROUP BY name)SELECT name, total_sales, RANK() OVER (ORDER BY total_sales DESC) as rankFROM aggregated; -- Or inline subquery:SELECT name, total_sales, RANK() OVER (ORDER BY total_sales DESC) as rankFROM ( SELECT name, SUM(sales) as total_sales FROM salespeople GROUP BY name) aggregated;We've explored ORDER BY in window functions comprehensively. Let's consolidate the key takeaways:
What's next:
Now that we understand how partitioning and ordering work together, we'll explore the final piece of the window function puzzle: the frame specification. Frames give you precise control over exactly which rows around the current row participate in the window function calculation.
You now have a comprehensive understanding of ORDER BY in window functions—how it defines calculation order, affects frame defaults, handles ties and NULLs, and works with expressions. Next, we'll master frame specifications for ultimate control over window function behavior.