MariaDB window functions (introduced in MariaDB 10.2) compute results across a set of rows related to the current row using the OVER() clause with PARTITION BY and ORDER BY. MariaDB window function syntax follows the SQL standard and is compatible with MySQL 8.0+ and PostgreSQL window function queries. Key window functions include ROW_NUMBER() for unique row numbering, RANK() and DENSE_RANK() for ranking with and without gaps, and LAG()/LEAD() for accessing previous and next row values.
ROW_NUMBER
MariaDB 10.2+→ BIGINT
Assigns a unique sequential integer to each row within the window partition, starting at 1.
Signature
ROW_NUMBER() OVER ([PARTITION BY col] ORDER BY col)
Examples
Basic row numbering by salary descending
sql
SELECTname, salary, ROW_NUMBER() OVER (ORDERBYsalaryDESC) ASrnFROMemployees;
→-- 1, 2, 3, ... unique for every row
Partitioned row numbering per department
sql
SELECTdept, name, salary, ROW_NUMBER() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASrnFROMemployees;
→-- numbering restarts at 1 for each department
With explicit frame clause (frame is noted but ROW_NUMBER ignores it)
sql
SELECTname, salary, ROW_NUMBER() OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrnFROMemployees;
→-- sequential number within running frame
Simple ascending order numbering
sql
SELECTROW_NUMBER() OVER (ORDERBYsalary) ASrn, salaryFROMemployees;
→-- 1, 2, 3 ...
In CTE to fetch top row per partition
sql
WITHrankedAS (SELECT*, ROW_NUMBER() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASrnFROMemployees) SELECT*FROMrankedWHERErn=1;
→-- top earner per department
⚠Anti-Pattern— Using ROW_NUMBER without ORDER BY
Omitting ORDER BY inside OVER() produces an arbitrary, non-deterministic numbering.
✓ Instead: Always specify ORDER BY inside the OVER() clause to get a predictable sequence.
The classic pattern for 'latest record per group' is to wrap ROW_NUMBER() OVER (PARTITION BY key ORDER BY date DESC) in a CTE and then filter WHERE rn = 1. This is more efficient than a correlated subquery on large tables.
example
WITHrankedAS (SELECT*, ROW_NUMBER() OVER (PARTITIONBYcustomer_idORDERBYorder_dateDESC) ASrnFROMorders) SELECT*FROMrankedWHERErn=1;
→one row per customer — their most recent order
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added ROW_NUMBER() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Assigns a rank to each row within the partition, with tied rows receiving the same rank and leaving gaps in the sequence.
Signature
RANK() OVER ([PARTITION BY col] ORDER BY col)
Examples
Basic ranking with ties sharing the same rank
sql
SELECTname, salary, RANK() OVER (ORDERBYsalaryDESC) ASrnkFROMemployees;
→-- 1, 1, 3, 4 ... (gaps after ties)
Partitioned ranking within each department
sql
SELECTdept, name, salary, RANK() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASrnkFROMemployees;
→-- rank resets per department
With frame clause (typically ignored for ranking functions)
sql
SELECTname, salary, RANK() OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrnkFROMemployees;
→-- rank within frame
Contest leaderboard with shared ranks for ties
sql
SELECTname, score, RANK() OVER (ORDERBYscoreDESC) ASrnkFROMcontest_results;
→-- tied scores share a rank
In CTE to fetch top-N per partition
sql
WITHrankedAS (SELECT*, RANK() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASrnkFROMemployees) SELECT*FROMrankedWHERErnk<=3;
→-- top 3 salaries per department (may return more than 3 rows if ties exist)
⚠Anti-Pattern— Expecting exactly N rows with RANK() <= N
If there are ties at the boundary, RANK() can return more rows than expected.
✓ Instead: Use ROW_NUMBER() for strict top-N limiting, or accept the extra tied rows intentionally.
RANK assigns the same number to tied rows but skips the next rank(s). If two rows tie for rank 2, the next row gets rank 4 (not 3). Use DENSE_RANK when you want sequential ranks without gaps in the leaderboard.
example
SELECTname, score, RANK() OVER (ORDERBYscoreDESC) ASr, DENSE_RANK() OVER (ORDERBYscoreDESC) ASdrFROMleaderboard;
→ties at rank 2 show r=2 for both but r=4 next; dr=2 and dr=3 next
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added RANK() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Assigns a rank to each row within the partition with no gaps in ranking values when ties occur.
Signature
DENSE_RANK() OVER ([PARTITION BY col] ORDER BY col)
Examples
Dense ranking — consecutive values even when ties exist
sql
SELECTname, salary, DENSE_RANK() OVER (ORDERBYsalaryDESC) ASdrnkFROMemployees;
→-- 1, 1, 2, 3 ... (no gaps after ties)
Partitioned dense ranking within each department
sql
SELECTdept, name, salary, DENSE_RANK() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASdrnkFROMemployees;
→-- dense rank resets per department
With frame clause
sql
SELECTname, salary, DENSE_RANK() OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASdrnkFROMemployees;
→-- dense rank within frame
Product revenue ranking without gaps
sql
SELECTproduct, revenue, DENSE_RANK() OVER (ORDERBYrevenueDESC) ASdrnkFROMsales;
→-- 1, 2, 2, 3 ...
In CTE to fetch second-tier salary per department
sql
WITHrankedAS (SELECT*, DENSE_RANK() OVER (PARTITIONBYdeptORDERBYsalaryDESC) ASdrnkFROMemployees) SELECT*FROMrankedWHEREdrnk=2;
→-- second-highest salary earners per department
⚠Anti-Pattern— Confusing DENSE_RANK with RANK
RANK skips numbers after ties (1,1,3), whereas DENSE_RANK does not (1,1,2); mixing them up leads to wrong nth-place logic.
✓ Instead: Use DENSE_RANK when nth-place semantics must be consecutive regardless of ties.
Because DENSE_RANK never skips numbers, it is perfect for mapping scores to letter grades, customer tiers, or prize brackets where every rank level must be occupied.
example
SELECTname, score, DENSE_RANK() OVER (ORDERBYscoreDESC) AStierFROMstudents;
→tier 1 for top scorers, tier 2 for next group — no gaps even with ties
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added DENSE_RANK() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Calculates the relative rank of a row as a percentage, returning a value between 0 and 1 inclusive.
Signature
PERCENT_RANK() OVER ([PARTITION BY col] ORDER BY col)
Examples
Basic percent rank across all employees
sql
SELECTname, salary, PERCENT_RANK() OVER (ORDERBYsalary) ASpct_rnkFROMemployees;
→-- 0.0, 0.25, 0.5, 0.75, 1.0 for 5 rows
Partitioned percent rank
sql
SELECTdept, name, salary, PERCENT_RANK() OVER (PARTITIONBYdeptORDERBYsalary) ASpct_rnkFROMemployees;
→-- percent rank within each department
With frame clause
sql
SELECTname, salary, PERCENT_RANK() OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASpct_rnkFROMemployees;
→-- percent rank within frame
Expressing percent rank as a 0–100 percentile
sql
SELECTname, score, ROUND(PERCENT_RANK() OVER (ORDERBYscore) *100, 1) ASpercentileFROMtest_scores;
→-- percentile expressed as 0–100
In CTE to filter top percentile
sql
WITHprAS (SELECT*, PERCENT_RANK() OVER (PARTITIONBYdeptORDERBYsalary) ASpctFROMemployees) SELECT*FROMprWHEREpct>=0.8;
→-- top 20% earners per department
⚠Anti-Pattern— Using PERCENT_RANK as a direct percentile value
PERCENT_RANK gives relative rank position, not a true distribution percentile; it equals 0 for the lowest value regardless of the distribution.
✓ Instead: Use PERCENTILE_CONT or PERCENTILE_DISC for true percentile calculations.
PERCENT_RANK is calculated as (rank - 1) / (total_rows - 1). The first row always returns 0.0 and is useful for percentile-based filtering, such as finding the top 10% of performers.
example
SELECTname, salary, PERCENT_RANK() OVER (ORDERBYsalary) ASpct_rankFROMemployees;
→top earner: pct_rank = 1.0, median earner: pct_rank ≈ 0.5
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added PERCENT_RANK() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Returns the cumulative distribution of a row within its partition: the proportion of rows with values less than or equal to the current row's value.
Signature
CUME_DIST() OVER ([PARTITION BY col] ORDER BY col)
Examples
Cumulative distribution across all employees
sql
SELECTname, salary, CUME_DIST() OVER (ORDERBYsalary) AScumeFROMemployees;
→-- 0.2, 0.4, 0.6, 0.8, 1.0 for 5 rows
Partitioned cumulative distribution
sql
SELECTdept, name, salary, CUME_DIST() OVER (PARTITIONBYdeptORDERBYsalary) AScumeFROMemployees;
→-- cumulative distribution within each department
With explicit frame clause
sql
SELECTname, salary, CUME_DIST() OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) AScumeFROMemployees;
→-- cumulative dist within frame
Express as a percentage
sql
SELECTname, salary, ROUND(CUME_DIST() OVER (ORDERBYsalary) *100, 1) AScume_pctFROMemployees;
→-- cumulative percentage
In CTE to select bottom half by salary
sql
WITHcdAS (SELECT*, CUME_DIST() OVER (PARTITIONBYdeptORDERBYsalary) AScumeFROMemployees) SELECT*FROMcdWHEREcume<=0.5;
→-- bottom 50% earners per department
⚠Anti-Pattern— Confusing CUME_DIST with PERCENT_RANK
CUME_DIST counts rows <= current row; PERCENT_RANK counts rows < current row — they differ especially at the boundaries.
✓ Instead: Use CUME_DIST when you need a value from (0,1] and PERCENT_RANK when you need [0,1).
CUME_DIST returns the cumulative distribution: the proportion of rows with values less than or equal to the current row's value. It is always between 0 and 1 (unlike PERCENT_RANK where the top row is exactly 1 and the bottom is 0).
example
SELECTproduct, price, CUME_DIST() OVER (ORDERBYprice) AScdfFROMproducts;
→products cheaper than 80% of the catalog have cume_dist ≤ 0.8
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added CUME_DIST() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Distributes rows into a specified number of ranked buckets and returns the bucket number (1 through N) for each row.
Signature
NTILE(N) OVER ([PARTITION BY col] ORDER BY col)
Parameters
Parameter
Type
Description
N
INTEGER
Number of buckets to divide rows into; must be a positive integer
Examples
Split employees into salary quartiles
sql
SELECTname, salary, NTILE(4) OVER (ORDERBYsalary) ASquartileFROMemployees;
→-- 1, 1, 2, 3, 4 ... (quartile bucket 1–4)
Partitioned quartile ranking within each department
sql
SELECTdept, name, salary, NTILE(4) OVER (PARTITIONBYdeptORDERBYsalary) ASquartileFROMemployees;
→-- quartile resets per department
With frame clause
sql
SELECTname, salary, NTILE(10) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASdecileFROMemployees;
→-- decile within frame
Divide students into three performance tiers
sql
SELECTname, score, NTILE(3) OVER (ORDERBYscoreDESC) AStierFROMexam_results;
→-- 1=top third, 2=middle, 3=bottom
In CTE to get top quintile products
sql
WITHbucketsAS (SELECT*, NTILE(5) OVER (ORDERBYrevenueDESC) ASquintileFROMproducts) SELECT*FROMbucketsWHEREquintile=1;
→-- top 20% revenue products
⚠Anti-Pattern— Using NTILE(N) expecting equal-sized buckets
NTILE distributes remainder rows to the first buckets, so bucket sizes can differ by one when rows don't divide evenly.
✓ Instead: Document that NTILE buckets can be unequal, or use integer arithmetic to compute bucket sizes manually.
NTILE(4) assigns each row to a quartile (1-4). When the row count is not exactly divisible by N, the first buckets receive one extra row. Use NTILE for A/B group assignment, histograms, and percentile-based segmentation.
example
SELECTcustomer_id, total_spend, NTILE(4) OVER (ORDERBYtotal_spendDESC) ASquartileFROMcustomer_summary;
Writing correlated subqueries or self-joins to access the previous row is verbose and performs poorly on large tables.
✓ Instead: Use LAG() for a concise, set-based approach that the optimizer handles efficiently.
LAG(col, 1) retrieves the previous row's value in the partition order, enabling difference and percentage-change calculations without a self-join. Provide a default third argument to avoid NULL on the first row of each partition.
example
SELECTmonth, revenue, revenue-LAG(revenue, 1, 0) OVER (ORDERBYmonth) ASmom_changeFROMmonthly_sales;
Without ORDER BY in the OVER clause, 'next row' is undefined and results are non-deterministic.
✓ Instead: Always specify ORDER BY within OVER() to define a meaningful row ordering for LEAD.
LEAD(col, 1) accesses the next row's value in the window. Use it for session analysis (time to next event), forecasting gap calculations, or detecting state transitions where the current row needs awareness of the following state.
example
SELECTsession_id, event_time, LEAD(event_time) OVER (PARTITIONBYsession_idORDERBYevent_time) -event_timeAStime_to_nextFROMevents;
→time between each event and the next within the same session
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added LEAD() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; behavior is the same.
Returns the value of an expression from the first row of the window frame.
Signature
FIRST_VALUE(expr) OVER ([PARTITION BY col] ORDER BY col [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to evaluate from the first row of the window frame
Examples
First salary in hire-date order for all rows
sql
SELECTname, salary, FIRST_VALUE(salary) OVER (ORDERBYhire_date) ASfirst_salaryFROMemployees;
→-- salary of the first employee hired
Partitioned: highest salary per department
sql
SELECTdept, name, salary, FIRST_VALUE(salary) OVER (PARTITIONBYdeptORDERBYsalaryDESC) AStop_salaryFROMemployees;
→-- highest salary in the department
With explicit ROWS frame for running first value
sql
SELECTname, salary, FIRST_VALUE(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASfirst_salFROMemployees;
→-- first salary seen so far within partition
Running first value in a time series
sql
SELECTdate, close, FIRST_VALUE(close) OVER (PARTITIONBYtickerORDERBYdateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASperiod_start_closeFROMstock_prices;
→-- opening price of the period for each ticker
In CTE to get top earner per department
sql
WITHfvAS (SELECT*, FIRST_VALUE(name) OVER (PARTITIONBYdeptORDERBYsalaryDESC) AStop_earnerFROMemployees) SELECTDISTINCTdept, top_earnerFROMfv;
→-- top earner name per department
⚠Anti-Pattern— Expecting the first row of the partition regardless of frame
FIRST_VALUE returns the first row of the frame, not necessarily the partition; if the frame starts at CURRENT ROW, FIRST_VALUE equals the current row's value.
✓ Instead: Explicitly specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to always get the partition-wide first value.
By default FIRST_VALUE uses the default frame which may not include all rows you expect. Explicitly use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to ensure the first value over the entire partition is returned consistently.
example
SELECTdept, name, salary, FIRST_VALUE(name) OVER (PARTITIONBYdeptORDERBYsalaryDESCROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) AStop_earnerFROMemployees;
→top_earner = highest-paid person in each department for every row
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added FIRST_VALUE() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Returns the value of an expression from the last row of the window frame.
Signature
LAST_VALUE(expr) OVER ([PARTITION BY col] ORDER BY col [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to evaluate from the last row of the window frame
Examples
Last salary in hire-date order — needs UNBOUNDED FOLLOWING frame
sql
SELECTname, salary, LAST_VALUE(salary) OVER (ORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASlast_salaryFROMemployees;
→-- salary of the most recently hired employee
Partitioned last value per department
sql
SELECTdept, name, salary, LAST_VALUE(name) OVER (PARTITIONBYdeptORDERBYsalaryROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASlowest_earnerFROMemployees;
→-- name of highest earner (last in ASC order) per dept
Running last value — current row is always the last of the running frame
sql
SELECTname, salary, LAST_VALUE(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASlatest_salFROMemployees;
→-- most recent salary seen so far in partition
Last close price for the whole period
sql
SELECTdate, close, LAST_VALUE(close) OVER (PARTITIONBYtickerORDERBYdateROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASperiod_end_closeFROMstock_prices;
→-- closing price of the period for each ticker
In CTE with DISTINCT to collapse partition result
sql
WITHlvAS (SELECT*, LAST_VALUE(name) OVER (PARTITIONBYdeptORDERBYsalaryROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASbest_earnerFROMemployees) SELECTDISTINCTdept, best_earnerFROMlv;
→-- best earner per department
⚠Anti-Pattern— Using LAST_VALUE with the default frame
With the default RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW frame, LAST_VALUE simply returns the current row's value, not the last row of the partition.
✓ Instead: Explicitly write ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to get the partition-level last value.
Without an explicit frame, LAST_VALUE uses ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — which means it only 'sees' values up to and including the current row, not the true last value in the partition. Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
example
SELECTname, salary, LAST_VALUE(name) OVER (PARTITIONBYdeptORDERBYsalaryROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASlowest_earnerFROMemployees;
→lowest_earner = least-paid person in each department for every row
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added LAST_VALUE() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Returns the value of an expression from the Nth row of the window frame (1-indexed), or NULL if the frame contains fewer than N rows.
Signature
NTH_VALUE(expr, N) OVER ([PARTITION BY col] ORDER BY col [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to evaluate from the Nth row of the window frame
N
INTEGER
Row position within the frame (1-indexed); must be a positive integer
Examples
Second-highest salary across all employees
sql
SELECTname, salary, NTH_VALUE(salary, 2) OVER (ORDERBYsalaryDESCROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASsecond_highestFROMemployees;
→-- second-highest salary repeated for every row
Partitioned: runner-up earner per department
sql
SELECTdept, name, salary, NTH_VALUE(name, 2) OVER (PARTITIONBYdeptORDERBYsalaryDESCROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASrunner_upFROMemployees;
→-- second-highest earner per department
With running frame — returns NULL until 3 rows accumulate
sql
SELECTname, NTH_VALUE(salary, 3) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASthird_salaryFROMemployees;
→-- third salary seen so far (NULL if fewer than 3 rows accumulated)
NTH_VALUE(expr, 1) is equivalent to FIRST_VALUE
sql
SELECTticker, date, NTH_VALUE(close, 1) OVER (PARTITIONBYtickerORDERBYdateROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASfirst_closeFROMstock_prices;
→-- equivalent to FIRST_VALUE
In CTE to extract second-place salary per department
sql
WITHnvAS (SELECT*, NTH_VALUE(salary, 2) OVER (PARTITIONBYdeptORDERBYsalaryDESCROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASsecond_salFROMemployees) SELECTDISTINCTdept, second_salFROMnv;
→-- second-highest salary per department
⚠Anti-Pattern— Omitting UNBOUNDED FOLLOWING from the frame
Without extending the frame to UNBOUNDED FOLLOWING, NTH_VALUE can return NULL for early rows because the Nth row has not yet entered the frame.
✓ Instead: Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to ensure the full partition is visible.
NTH_VALUE(col, 2) returns the second row in the window order, NTH_VALUE(col, 3) the third, etc. Like LAST_VALUE, use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to access values beyond the current row's position.
example
SELECTdept, name, salary, NTH_VALUE(name, 2) OVER (PARTITIONBYdeptORDERBYsalaryDESCROWSBETWEENUNBOUNDEDPRECEDINGANDUNBOUNDEDFOLLOWING) ASsecond_earnerFROMemployees;
→second_earner = second-highest paid person per department
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added NTH_VALUE() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Computes the sum of an expression over the window frame, enabling running totals and partitioned aggregation without collapsing rows.
Signature
SUM(expr) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
Parameters
Parameter
Type
Description
expr
NUMERIC
Numeric expression to sum over the window frame
Examples
Grand total without collapsing rows
sql
SELECTname, salary, SUM(salary) OVER () AStotal_payrollFROMemployees;
→-- total payroll repeated for every row
Partitioned sum per department
sql
SELECTdept, name, salary, SUM(salary) OVER (PARTITIONBYdept) ASdept_totalFROMemployees;
→-- department payroll repeated for each row in that dept
Running sum with ROWS frame
sql
SELECTname, salary, SUM(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_totalFROMemployees;
→-- running payroll total within department
7-day rolling window sum
sql
SELECTdate, amount, SUM(amount) OVER (ORDERBYdateROWSBETWEEN6PRECEDINGANDCURRENTROW) ASrolling_7dayFROMdaily_sales;
→-- 7-day rolling revenue total
In CTE to compute YTD revenue
sql
WITHrsAS (SELECT*, SUM(revenue) OVER (PARTITIONBYregionORDERBYmonthROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASytdFROMmonthly_revenue) SELECT*FROMrsWHEREmonth=12;
→-- year-to-date revenue per region at December
⚠Anti-Pattern— Using GROUP BY instead of SUM OVER for running totals
Grouping aggregates remove individual rows, requiring a self-join to produce a running total alongside row-level data.
✓ Instead: Use SUM() OVER with an ORDER BY and ROWS frame to compute running totals without losing row granularity.
SUM(col) OVER (PARTITION BY key ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) computes a running total in a single pass. Replace any correlated subquery that sums all prior rows with this window function for dramatically better performance.
example
SELECTorder_date, amount, SUM(amount) OVER (ORDERBYorder_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_totalFROMorders;
→running_total increases with each row in date order
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added window SUM() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Computes the average of an expression over the window frame, enabling moving averages and partitioned means without collapsing rows.
Signature
AVG(expr) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
Parameters
Parameter
Type
Description
expr
NUMERIC
Numeric expression to average over the window frame
Examples
Global average without collapsing rows
sql
SELECTname, salary, AVG(salary) OVER () AScompany_avgFROMemployees;
→-- company-wide average salary on every row
Partitioned average per department
sql
SELECTdept, name, salary, AVG(salary) OVER (PARTITIONBYdept) ASdept_avgFROMemployees;
→-- department average salary for each row
Running average with ROWS frame
sql
SELECTname, salary, AVG(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_avgFROMemployees;
→-- running average salary within department
7-day moving average
sql
SELECTdate, price, AVG(price) OVER (ORDERBYdateROWSBETWEEN6PRECEDINGANDCURRENTROW) ASma7FROMstock_prices;
→-- 7-day moving average price
In CTE for centered moving average
sql
WITHmaAS (SELECT*, AVG(revenue) OVER (PARTITIONBYproductORDERBYmonthROWSBETWEEN2PRECEDINGAND2FOLLOWING) AScentered_avgFROMmonthly_revenue) SELECT*FROMma;
→-- 5-month centered moving average per product
⚠Anti-Pattern— Computing group average with a correlated subquery
Correlated subqueries that compute a GROUP BY average for each row are O(N^2) and scale poorly.
✓ Instead: Use AVG() OVER (PARTITION BY ...) for a single-pass computation.
RANGE BETWEEN N PRECEDING AND CURRENT ROW uses value-based bounds which can include varying numbers of rows when values repeat. Use ROWS BETWEEN N PRECEDING AND CURRENT ROW for a fixed-size window (e.g., exactly 7 rows for a 7-day moving average).
example
SELECTdate, price, AVG(price) OVER (ORDERBYdateROWSBETWEEN6PRECEDINGANDCURRENTROW) ASma7FROMstock_prices;
→ma7 = 7-day moving average, exactly 7 rows wide
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added window AVG() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Counts rows or non-NULL values within the window frame, enabling running counts and partitioned row counts without collapsing rows.
Signatures
COUNT(*) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
COUNT(expr) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to count non-NULL values of; use * to count all rows
Examples
Total row count without collapsing
sql
SELECTname, dept, COUNT(*) OVER () AStotal_employeesFROMemployees;
→-- total headcount on every row
Partitioned count per department
sql
SELECTdept, name, COUNT(*) OVER (PARTITIONBYdept) ASdept_countFROMemployees;
→-- headcount per department on every row in that dept
Running count of hires within department
sql
SELECTname, COUNT(*) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) AShire_seqFROMemployees;
→-- sequential hire count within department
Rolling 30-row window count
sql
SELECTdate, event, COUNT(*) OVER (ORDERBYdateROWSBETWEEN29PRECEDINGANDCURRENTROW) ASevents_last_30FROMevent_log;
→-- rolling 30-row event count
In CTE to identify first order per customer
sql
WITHccAS (SELECT*, COUNT(order_id) OVER (PARTITIONBYcustomer_idORDERBYorder_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASorder_numFROMorders) SELECT*FROMccWHEREorder_num=1;
→-- first order per customer
⚠Anti-Pattern— Using a subquery to get partition size for percentage calculation
Joining back to a grouped subquery to get partition size is verbose and harder to maintain.
✓ Instead: Use COUNT(*) OVER (PARTITION BY ...) to get the partition size inline.
SELECT *, COUNT(*) OVER () AS total FROM table LIMIT 20 returns both the page of results and the total row count in a single query — no separate COUNT(*) query needed. This is a clean pattern for paginated APIs.
example
SELECTid, name, COUNT(*) OVER () AStotal_countFROMproductsWHEREactive=1LIMIT20OFFSET0;
→20 rows, each with total_count = 347 (total matching rows)
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added window COUNT() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Returns the maximum value of an expression over the window frame, enabling running maximums and partitioned peak values without collapsing rows.
Signature
MAX(expr) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to find the maximum of over the window frame
Examples
Global maximum without collapsing rows
sql
SELECTname, salary, MAX(salary) OVER () AShighest_salaryFROMemployees;
→-- highest salary in the company on every row
Partitioned maximum per department
sql
SELECTdept, name, salary, MAX(salary) OVER (PARTITIONBYdept) ASdept_maxFROMemployees;
→-- department maximum salary for every row in that dept
Running maximum with ROWS frame
sql
SELECTname, salary, MAX(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_maxFROMemployees;
→-- highest salary seen so far within department
Rolling 7-day high
sql
SELECTdate, high, MAX(high) OVER (ORDERBYdateROWSBETWEEN6PRECEDINGANDCURRENTROW) ASweek_highFROMstock_prices;
→-- 7-day rolling high
In CTE to filter rows matching the partition maximum
sql
WITHmxAS (SELECT*, MAX(salary) OVER (PARTITIONBYdept) ASdept_maxFROMemployees) SELECT*FROMmxWHEREsalary=dept_max;
→-- top earners in each department
⚠Anti-Pattern— Filtering with a correlated MAX subquery
Writing WHERE salary = (SELECT MAX(salary) FROM employees e2 WHERE e2.dept = employees.dept) is evaluated per row.
✓ Instead: Use MAX() OVER (PARTITION BY dept) in a CTE and filter in the outer query for better readability and often better performance.
Unlike the aggregate MAX which collapses rows, the window MAX adds the maximum value as a new column on every row. Use this to calculate the percentage of maximum for each row: col / MAX(col) OVER (PARTITION BY grp).
example
SELECTdept, name, salary, MAX(salary) OVER (PARTITIONBYdept) ASdept_max, salary/MAX(salary) OVER (PARTITIONBYdept) ASpct_of_maxFROMemployees;
→each row shows salary relative to the department's top earner
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added window MAX() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
Returns the minimum value of an expression over the window frame, enabling running minimums and partitioned floor values without collapsing rows.
Signature
MIN(expr) OVER ([PARTITION BY col] [ORDER BY col] [frame_clause])
Parameters
Parameter
Type
Description
expr
ANY
Expression to find the minimum of over the window frame
Examples
Global minimum without collapsing rows
sql
SELECTname, salary, MIN(salary) OVER () ASlowest_salaryFROMemployees;
→-- lowest salary in the company on every row
Partitioned minimum per department
sql
SELECTdept, name, salary, MIN(salary) OVER (PARTITIONBYdept) ASdept_minFROMemployees;
→-- department minimum salary for every row in that dept
Running minimum with ROWS frame
sql
SELECTname, salary, MIN(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_minFROMemployees;
→-- lowest salary seen so far within department
Rolling 7-day low
sql
SELECTdate, low, MIN(low) OVER (ORDERBYdateROWSBETWEEN6PRECEDINGANDCURRENTROW) ASweek_lowFROMstock_prices;
→-- 7-day rolling low
In CTE to detect all-time low price events
sql
WITHmnAS (SELECT*, MIN(price) OVER (PARTITIONBYproduct_idORDERBYsale_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) AShist_minFROMprices) SELECT*FROMmnWHEREprice=hist_min;
→-- rows where price hit an all-time low
⚠Anti-Pattern— Using a correlated subquery to find the minimum for comparison
Correlated subqueries like (SELECT MIN(price) FROM prices p2 WHERE p2.product_id = prices.product_id) execute once per row.
✓ Instead: Use MIN() OVER (PARTITION BY product_id) for a single-scan solution.
The window MIN adds the partition minimum to every row without collapsing the result set. Use it to compute relative distance from the minimum: col - MIN(col) OVER (PARTITION BY grp) for normalization or range calculations.
example
SELECTproduct, price, MIN(price) OVER (PARTITIONBYcategory) AScat_min_priceFROMproducts;
→every product row shows the cheapest price in its category
◆MariaDB Note
Available since MariaDB 10.2.
⇄ vs MySQL: MySQL added window MIN() in 8.0; MariaDB had it since 10.2.
⇄ vs PostgreSQL: PostgreSQL syntax is identical; PostgreSQL also supports GROUPS frame mode.
→-- each employee row annotated with their dept median
⚠Anti-Pattern— Using AVG to estimate the median
The average is strongly skewed by outliers and is not the median; on a right-skewed salary distribution, AVG can be far above the true middle.
✓ Instead: Use PERCENTILE_CONT(0.5) for a true interpolated median.
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY col) returns the exact median by linear interpolation between the two middle values when there is an even count. This is the statistically correct median for continuous variables like response time or income.
Computes a discrete percentile value by returning the first actual value in the sorted set whose cumulative distribution meets or exceeds the given fraction.
Signature
PERCENTILE_DISC(fraction) WITHIN GROUP (ORDER BY expr) OVER ([PARTITION BY col])
Parameters
Parameter
Type
Description
fraction
DOUBLE
Percentile fraction between 0 and 1 (e.g., 0.5 for median)
expr
ANY
Expression defining the sort order for the percentile calculation
Examples
Company-wide discrete median — returns a real observed salary
sql
SELECTPERCENTILE_DISC(0.5) WITHINGROUP (ORDERBYsalary) OVER () ASmedian_salaryFROMemployeesLIMIT1;
→-- actual median value that exists in the data
Partitioned discrete median per department
sql
SELECTDISTINCTdept, PERCENTILE_DISC(0.5) WITHINGROUP (ORDERBYsalary) OVER (PARTITIONBYdept) ASdept_medianFROMemployees;
→-- discrete median salary per department
75th percentile discrete value
sql
SELECTname, dept, PERCENTILE_DISC(0.75) WITHINGROUP (ORDERBYsalary) OVER (PARTITIONBYdept) ASp75_salaryFROMemployees;
→-- 75th percentile actual salary value per department
Discrete median latency per API endpoint
sql
SELECTendpoint, PERCENTILE_DISC(0.5) WITHINGROUP (ORDERBYresponse_ms) OVER (PARTITIONBYendpoint) ASmedian_latencyFROMapi_logs;
→-- actual observed median response time per endpoint
In CTE to report discrete median per department
sql
WITHpctAS (SELECTDISTINCTdept, PERCENTILE_DISC(0.5) WITHINGROUP (ORDERBYsalary) OVER (PARTITIONBYdept) ASp50FROMemployees) SELECT*FROMpctORDERBYdept;
→-- median salary per department as actual observed value
⚠Anti-Pattern— Using PERCENTILE_DISC when a smooth interpolation is needed
PERCENTILE_DISC returns an existing value; on small datasets the result can be far from the true percentile position.
✓ Instead: Use PERCENTILE_CONT for a smoothly interpolated percentile on continuous data.
PERCENTILE_DISC(0.5) returns the first actual value in the sorted order that reaches or exceeds the target percentile. Unlike PERCENTILE_CONT it never returns an interpolated value, making it suitable for integer counts, ordinal ranks, and categorical scores.
Returns the median (50th percentile) of values in a group using continuous interpolation; a convenient shorthand for PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY expr).
Signature
MEDIAN(expr) OVER ([PARTITION BY col])
Parameters
Parameter
Type
Description
expr
NUMERIC
Numeric expression whose median is calculated over the window
Examples
Company-wide median without collapsing rows
sql
SELECTname, salary, MEDIAN(salary) OVER () AScompany_medianFROMemployees;
→-- interpolated median salary on every row
Partitioned median per department
sql
SELECTdept, name, salary, MEDIAN(salary) OVER (PARTITIONBYdept) ASdept_medianFROMemployees;
→-- median salary per department on every row
With frame clause for running median
sql
SELECTname, salary, MEDIAN(salary) OVER (PARTITIONBYdeptORDERBYhire_dateROWSBETWEENUNBOUNDEDPRECEDINGANDCURRENTROW) ASrunning_medianFROMemployees;
→-- running median within department
Median sale amount per product
sql
SELECTproduct_id, sale_date, amount, MEDIAN(amount) OVER (PARTITIONBYproduct_id) ASmedian_saleFROMsales;
→-- median sale amount per product on every row
In CTE to compare individual salary to department median
⚠Anti-Pattern— Computing median with AVG of middle two rows
Hand-rolling a median with ROW_NUMBER tricks and conditional aggregation is error-prone and verbose.
✓ Instead: Use MEDIAN() OVER (...) for a clean, correct, and readable median calculation.
MEDIAN(col) OVER (PARTITION BY grp) annotates every row with the median of its partition, allowing per-row comparison: how far is this value from the partition median? This pattern is common in outlier detection.
example
SELECTname, salary, MEDIAN(salary) OVER (PARTITIONBYdept) ASdept_median, salary-MEDIAN(salary) OVER (PARTITIONBYdept) ASvs_medianFROMemployees;
→vs_median shows how each person's salary deviates from the department median
◆MariaDB Note
MEDIAN is a MariaDB extension available since MariaDB 10.3.3.
⇄ vs MySQL: MySQL does not support MEDIAN as a window function.
⇄ vs PostgreSQL: PostgreSQL does not have a MEDIAN shorthand; use PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY expr) instead.