Understanding SQL: Getting Started with Window Functions
Introduction
When it comes to aggregating data in SQL, window functions provide greater flexibility than aggregations used in conjunction with GROUP BY
clauses. While both approaches indeed perform similar functions, window functions differ by virtue of how the outputs are structured. Specifically, window functions apply operations across a set of related rows, where the relation is determined by some grouping, or partition, of the table rows. And, unlike their non-window counterparts that collapse rows into a single output row, all rows retain their separate identities and are present in the output table.
This behaviour is quite unlike your usual run of the mill aggregation and can greatly extend your analytical toolbox beyond simple summary statistics. For example, window functions allows us to compute running sums, moving averages, and even statistical measures like z-scores.
In this post we're going to take a look at the anatomy and basic functionality of SQL window functions. The focus here is somewhat rudimentary, so if you haven't come across window functions, or have limited experience using them, this will hopefully be of some interest to you.
For this post we'll be using some high level summary data about the FIFA World Cup competitions between 1930 and 2022. The placings and statistics were sourced from Wikipedia and is made available under the Creative Commons Attribution Share-Alike license (CC-BY-SA). The data and related information can be found here. For the purpose of this blog I imported the table into my own PostgresSQL database, but if you want to follow along you can grab a copy of the table from my Git repository. In my database this table is called world_cup_placings
and an output is shown below:
|year|start_date|end_date|host_country |first_place |second_place |third_place |fourth_place|total_teams|matches_played|total_goals|total_attendance|
|----|----------|--------|-------------|------------|--------------|-------------|------------|-----------|--------------|-----------|----------------|
|1930|13/07/30 |30/07/30|Uruguay |Uruguay |Argentina |United States|Yugoslavia |13 |18 |70 |590,549 |
|1934|27/05/34 |10/06/34|Italy |Italy |Czechoslovakia|Germany |Austria |16 |17 |70 |363,000 |
|1938|4/06/38 |19/06/38|France |Italy |Hungary |Brazil |Sweden |15 |18 |84 |374,835 |
|1950|24/06/50 |16/07/50|Brazil |Uruguay |Brazil |Sweden |Spain |13 |22 |88 |1,045,246 |
|1954|16/06/54 |4/07/54 |Switerland |West Germany|Hungary |Austria |Uruguay |16 |26 |140 |768,607 |
|1958|8/06/58 |29/06/58|Sweden |Brazil |Sweden |France |West Germany|16 |35 |126 |819,810 |
|1962|30/05/62 |17/06/62|Chile |Brazil |Czechoslovakia|Chile |Yugoslavia |16 |32 |89 |893,172 |
|1966|11/07/66 |30/07/66|England |England |West Germany |Portugal |Soviet Union|16 |32 |89 |1,563,135 |
|1970|31/05/70 |21/06/70|Mexico |Brazil |Italy |West Germany |Uruguay |16 |32 |95 |1,604,065 |
|1974|13/06/74 |7/07/74 |West Germany |West Germany|Netherlands |Poland |Brazil |16 |38 |97 |1,865,762 |
|1978|1/06/78 |25/06/78|Argentina |Argentina |Netherlands |Brazil |Italy |16 |38 |102 |1,545,791 |
|1982|13/06/82 |11/07/82|Spain |Italy |West Germany |Poland |France |24 |52 |146 |2,109,723 |
|1986|31/05/86 |29/06/86|Mexico |Argentina |West Germany |France |Belgium |24 |52 |132 |2,394,031 |
|1990|8/06/90 |8/07/90 |Italy |West Germany|Argentina |Italy |England |24 |52 |115 |2,516,215 |
|1994|17/06/94 |17/07/94|United States|Brazil |Italy |Sweden |Bulgaria |24 |52 |141 |3,597,042 |
|1998|10/06/98 |12/07/98|France |France |Brazil |Croatia |Netherlands |32 |64 |171 |2,785,100 |
|2002|31/05/02 |30/06/02|Korea / Japan|Brazil |Germany |Turkey |South Korea |32 |64 |161 |2,705,198 |
|2006|9/06/06 |9/07/06 |Germany |Italy |France |Germany |Portugal |32 |64 |147 |3,359,439 |
|2010|11/06/10 |11/07/10|South Africa |Spain |Netherlands |Germany |Uruguay |32 |64 |145 |3,178,856 |
|2014|12/06/14 |13/07/14|Brazil |Germany |Argentina |Netherlands |Brazil |32 |64 |171 |3,429,873 |
|2018|14/06/18 |15/07/18|Russia |France |Croatia |Belgium |England |32 |64 |169 |3,031,768 |
|2022|20/11/22 |18/12/22|Qatar |Argentina |France |Croatia |Morocco |32 |64 |172 |3,404,252 |
A quick note on execution order
The order in which SQL executes each clause is important to understand, so let's just take a few minutes to examine where window functions fall in the order of execution.
Window functions can only be used within SELECT
lists and theORDER BY
clause. They cannot be used with GROUP BY
, HAVING
, or WHERE
clauses. The reason for this is that window functions are executed after these clauses have been processed. Another thing to note is that window functions are processed after non-window aggregate functions (i.e., SUM
, MAX,
AVG
, etc). As we'll see later, this is useful because it means we can actually use these functions within our window function.
The OVER clause
First, let's take a look at the simplest version of a window function:
FUNCTION_NAME() OVER()
The FUNCTION_NAME()
is just a placeholder for whatever function you wish to use; however, window functions must always contain an OVER
clause. This clause is what distinguishes window from non-window functions and its role is to determine how the rows are split up for processing. In the example above, though, no arguments have been passed to OVER
. This is perfectly legitimate so lets see what this looks like in practice.
A common use case for window functions is to assign a numerical value to each row in the table. This can be achieved using the inbuilt ROW_NUMBER
function. For example, consider the example Query below:
/* Assigning numbers to each row in the table */
SELECT
"year"
,host_country
,first_place
,total_goals
,ROW_NUMBER() OVER() AS row_num
FROM world_cup_placings;
;
|year|host_country |first_place |total_goals|row_num|
|----|-------------|------------|-----------|-------|
|1930|Uruguay |Uruguay |70 |1 |
|1934|Italy |Italy |70 |2 |
|1938|France |Italy |84 |3 |
|1950|Brazil |Uruguay |88 |4 |
|1954|Switerland |West Germany|140 |5 |
|1958|Sweden |Brazil |126 |6 |
|1962|Chile |Brazil |89 |7 |
|1966|England |England |89 |8 |
|1970|Mexico |Brazil |95 |9 |
|1974|West Germany |West Germany|97 |10 |
|1978|Argentina |Argentina |102 |11 |
|1982|Spain |Italy |146 |12 |
|1986|Mexico |Argentina |132 |13 |
|1990|Italy |West Germany|115 |14 |
|1994|United States|Brazil |141 |15 |
|1998|France |France |171 |16 |
|2002|Korea / Japan|Brazil |161 |17 |
|2006|Germany |Italy |147 |18 |
|2010|South Africa |Spain |145 |19 |
|2014|Brazil |Germany |171 |20 |
|2018|Russia |France |169 |21 |
|2022|Qatar |Argentina |172 |22 |
We now have a newly minted column called row_num
that contains a sequential list of numbers; one for each table row. With just the vanilla OVER
clause the window function treats the entire table as a single partition. That's because we haven't told it anything otherwise.
Let's see what happens if we replace ROW_NUMBER
with an aggregate function, like SUM
, and apply it to the total_goals
columns, which is the total number of goals during the entire competition. The query for this is provided next:
Sql">/* Using SUM() within our window function */
SELECT
"year"
,host_country
,first_place
,total_goals
,SUM(total_goals) OVER() AS all_goals
FROM world_cup_placings
;
|year|host_country |first_place |total_goals|all_goals|
|----|-------------|------------|-----------|---------|
|1930|Uruguay |Uruguay |70 |2,720 |
|1934|Italy |Italy |70 |2,720 |
|1938|France |Italy |84 |2,720 |
|1950|Brazil |Uruguay |88 |2,720 |
|1954|Switerland |West Germany|140 |2,720 |
|1958|Sweden |Brazil |126 |2,720 |
|1962|Chile |Brazil |89 |2,720 |
|1966|England |England |89 |2,720 |
|1970|Mexico |Brazil |95 |2,720 |
|1974|West Germany |West Germany|97 |2,720 |
|1978|Argentina |Argentina |102 |2,720 |
|1982|Spain |Italy |146 |2,720 |
|1986|Mexico |Argentina |132 |2,720 |
|1990|Italy |West Germany|115 |2,720 |
|1994|United States|Brazil |141 |2,720 |
|1998|France |France |171 |2,720 |
|2002|Korea / Japan|Brazil |161 |2,720 |
|2006|Germany |Italy |147 |2,720 |
|2010|South Africa |Spain |145 |2,720 |
|2014|Brazil |Germany |171 |2,720 |
|2018|Russia |France |169 |2,720 |
|2022|Qatar |Argentina |172 |2,720 |
Okay, so we just get a single value for each row – but this is exactly what we should expect to see. Remember, window functions are applied across all rows within a partition, and here (like above) the window function is treating the entire table as a partition. As a result, it will just sum all values in the total_goals
column. Also, window functions retain row identity and so this output value is repeated for each output row. Note also that we're using an aggregation function within the window function – this is possible because window functions are processed after aggregate functions.
Right, let's see how far we can push this approach by computing the average number of goals scored across all competitions (note the rounding off for output value):
/* Computing the average number of goals */
SELECT
"year"
,host_country
,first_place
,total_goals
,ROUND(AVG(total_goals) OVER(), 0) AS mean_goals
FROM world_cup_placings
;
|year|host_country |first_place |total_goals|mean_goals|
|----|-------------|------------|-----------|----------|
|1930|Uruguay |Uruguay |70 |124 |
|1934|Italy |Italy |70 |124 |
|1938|France |Italy |84 |124 |
|1950|Brazil |Uruguay |88 |124 |
|1954|Switerland |West Germany|140 |124 |
|1958|Sweden |Brazil |126 |124 |
|1962|Chile |Brazil |89 |124 |
|1966|England |England |89 |124 |
|1970|Mexico |Brazil |95 |124 |
|1974|West Germany |West Germany|97 |124 |
|1978|Argentina |Argentina |102 |124 |
|1982|Spain |Italy |146 |124 |
|1986|Mexico |Argentina |132 |124 |
|1990|Italy |West Germany|115 |124 |
|1994|United States|Brazil |141 |124 |
|1998|France |France |171 |124 |
|2002|Korea / Japan|Brazil |161 |124 |
|2006|Germany |Italy |147 |124 |
|2010|South Africa |Spain |145 |124 |
|2014|Brazil |Germany |171 |124 |
|2018|Russia |France |169 |124 |
|2022|Qatar |Argentina |172 |124 |
Now, this is quite useful. Having the average number of goals listed alongside the competition totals allows us to directly compare these values. We can easily see how individual totals compare to the average taken across all competitions.
Let's push this even further by computing a z-score for each row. To do so we'll also need to use the STDDEV
function within another window function. The query below shows you how to do this:
/* Compute z-score for total goals scored */
SELECT
"year"
,host_country
,first_place
,total_goals
,ROUND(AVG(total_goals) OVER(), 0) AS mean_goals
,ROUND((total_goals - AVG(total_goals) OVER()) /
STDDEV(total_goals) OVER(), 2) AS z_score
FROM world_cup_placings
;
|year|host_country |first_place |total_goals|mean_goals|z_score|
|----|-------------|------------|-----------|----------|-------|
|1930|Uruguay |Uruguay |70 |124 |-1.54 |
|1934|Italy |Italy |70 |124 |-1.54 |
|1938|France |Italy |84 |124 |-1.14 |
|1950|Brazil |Uruguay |88 |124 |-1.02 |
|1954|Switerland |West Germany|140 |124 |0.47 |
|1958|Sweden |Brazil |126 |124 |0.07 |
|1962|Chile |Brazil |89 |124 |-0.99 |
|1966|England |England |89 |124 |-0.99 |
|1970|Mexico |Brazil |95 |124 |-0.82 |
|1974|West Germany |West Germany|97 |124 |-0.76 |
|1978|Argentina |Argentina |102 |124 |-0.62 |
|1982|Spain |Italy |146 |124 |0.64 |
|1986|Mexico |Argentina |132 |124 |0.24 |
|1990|Italy |West Germany|115 |124 |-0.25 |
|1994|United States|Brazil |141 |124 |0.5 |
|1998|France |France |171 |124 |1.36 |
|2002|Korea / Japan|Brazil |161 |124 |1.07 |
|2006|Germany |Italy |147 |124 |0.67 |
|2010|South Africa |Spain |145 |124 |0.61 |
|2014|Brazil |Germany |171 |124 |1.36 |
|2018|Russia |France |169 |124 |1.3 |
|2022|Qatar |Argentina |172 |124 |1.39 |
That's looking pretty good!
The PARTITION BY clause
Up until this point I have been using the term partition a lot, so let's take a look at what this actually means. Recall that in the earlier examples we didn't explicitly state how we wanted the table partitioned, so operations were performed across all rows in the table. The PARTITION BY
clause, on the other hand, is called within the OVER
clause and it dictates how rows should be divided into groups, or partitions. With this clause included the anatomy of the window function now looks like this:
FUNCTION_NAME() OVER( PARTITION BY [var] )
The placeholder [var]
refers to a column that is used to group rows. To demonstrate, let's take another crack at numbering rows using the ROW_NUMBER
function, only this time we'll use the first_place
column to partition the rows. Check out the query below:
/* Add row numbers to each partition */
SELECT
"year"
,host_country
,first_place
,total_goals
,ROW_NUMBER() OVER( PARTITION BY first_place ) AS row_num
FROM world_cup_placings
;
|year|host_country |first_place |total_goals|row_num|
|----|-------------|------------|-----------|-------|
|1978|Argentina |Argentina |102 |1 |
|1986|Mexico |Argentina |132 |2 |
|2022|Qatar |Argentina |172 |3 |
|1962|Chile |Brazil |89 |1 |
|2002|Korea / Japan|Brazil |161 |2 |
|1994|United States|Brazil |141 |3 |
|1958|Sweden |Brazil |126 |4 |
|1970|Mexico |Brazil |95 |5 |
|1966|England |England |89 |1 |
|1998|France |France |171 |1 |
|2018|Russia |France |169 |2 |
|2014|Brazil |Germany |171 |1 |
|1982|Spain |Italy |146 |1 |
|1934|Italy |Italy |70 |2 |
|1938|France |Italy |84 |3 |
|2006|Germany |Italy |147 |4 |
|2010|South Africa |Spain |145 |1 |
|1930|Uruguay |Uruguay |70 |1 |
|1950|Brazil |Uruguay |88 |2 |
|1974|West Germany |West Germany|97 |1 |
|1954|Switerland |West Germany|140 |2 |
|1990|Italy |West Germany|115 |3 |
Okay, things are looking very different to last time. First, we can see that the output has ordered the table using the first_place
column, but that's not all that interesting. What is interesting are the changes to the row_num
column. Starting at the top and working down, we can see that the number sequence resets for each distinct value in first_place
, counting only the rows associated with each country's partition.
Let's build on our query some more. In addition to the row_num
column let's compute the total number of goals scored and the maximum total attendance. We'll again use the first_place
column to partition the table, so these aggregations will apply only to the rows associated with each partition. The query below shows you how to do this:
/* Adding more window functions to the SELECT list */
SELECT
"year"
,host_country
,first_place
,ROW_NUMBER() OVER( PARTITION BY first_place ) AS row_num
,total_goals
,SUM(total_goals) OVER( PARTITION BY first_place ) AS all_goals
,total_attendance
,MAX(total_attendance) OVER( PARTITION BY first_place ) AS max_attendance
FROM world_cup_placings
;
|year|host_country |first_place |row_num|total_goals|all_goals|total_attendance|max_attednance|
|----|-------------|------------|-------|-----------|---------|----------------|--------------|
|1978|Argentina |Argentina |1 |102 |406 |1,545,791 |3,404,252 |
|1986|Mexico |Argentina |2 |132 |406 |2,394,031 |3,404,252 |
|2022|Qatar |Argentina |3 |172 |406 |3,404,252 |3,404,252 |
|1962|Chile |Brazil |1 |89 |612 |893,172 |3,597,042 |
|2002|Korea / Japan|Brazil |2 |161 |612 |2,705,198 |3,597,042 |
|1994|United States|Brazil |3 |141 |612 |3,597,042 |3,597,042 |
|1958|Sweden |Brazil |4 |126 |612 |819,810 |3,597,042 |
|1970|Mexico |Brazil |5 |95 |612 |1,604,065 |3,597,042 |
|1966|England |England |1 |89 |89 |1,563,135 |1,563,135 |
|1998|France |France |1 |171 |340 |2,785,100 |3,031,768 |
|2018|Russia |France |2 |169 |340 |3,031,768 |3,031,768 |
|2014|Brazil |Germany |1 |171 |171 |3,429,873 |3,429,873 |
|1982|Spain |Italy |1 |146 |447 |2,109,723 |3,359,439 |
|1934|Italy |Italy |2 |70 |447 |363,000 |3,359,439 |
|1938|France |Italy |3 |84 |447 |374,835 |3,359,439 |
|2006|Germany |Italy |4 |147 |447 |3,359,439 |3,359,439 |
|2010|South Africa |Spain |1 |145 |145 |3,178,856 |3,178,856 |
|1930|Uruguay |Uruguay |1 |70 |158 |590,549 |1,045,246 |
|1950|Brazil |Uruguay |2 |88 |158 |1,045,246 |1,045,246 |
|1974|West Germany |West Germany|1 |97 |352 |1,865,762 |2,516,215 |
|1954|Switerland |West Germany|2 |140 |352 |768,607 |2,516,215 |
|1990|Italy |West Germany|3 |115 |352 |2,516,215 |2,516,215 |
Now, neither of these aggregations are particularly useful in and of themselves and they're really just for demonstration purposes. But let's take a look at what we actually get back from this query.
First, the all_goals
column provides the total number of goals scored across all competitions for each winning country (recall, we have partitioned rows using the first_place
column). For example, in competitions where Argentina won, we can see that a total of 102 goals were scored when Argentina hosted in 1978, 132 goals were scored in 1986 when Mexico hosted, and 172 goals most recently in Qatar. The all_goals
value, then, is simply the sum of these two values, which is 406.
Second, the max_attendance
value returns the highest competition attendance for each winning country. For example, of the World Cups listed in the table, Italy has won four of them, with the highest attendance recorded during the 2006 World Cup held in Germany (3,359,439). So, for all rows where Italy was the winner, this is value returned for max_attendance
.
Looking back over the query, you might have noticed that we need write OVER( PARTITION BY first_place )
three times; once for each window function in the SELECT
list. This can become a little tedious if your query requires multiple window functions that partition using the same column. So is there a better to achieve the same goal? Yes. Yes there is. In cases like these – where the windowing is the same for all functions – we can use a separate WINDOW
clause to define the partition and assign it a name that can be called by OVER
. Take a look at the query below:
/* Same as above, but now using the WINDOW clause */
SELECT
"year"
,host_country
,first_place
,ROW_NUMBER() OVER w AS row_num
,total_goals
,SUM(total_goals) OVER w AS all_goals
,total_attendance
,MAX(total_attendance) OVER w AS max_attendance
FROM world_cup_placings
WINDOW w AS ( PARTITION BY first_place )
;
Now, this doesn't eliminate the need to provide an argument after the OVER
clause – that can't be avoided – but this approach is far less error-prone and definitely helps declutter the query. You can check that the results are identical to the output above.
The ORDER BY clause
Another clause that can be added to OVER
is the ORDER BY
clause. This clause will certainly look familiar, and indeed it works exactly how you'd expect it to. The only difference is that when we use it within the OVER
clause it affects the ordering within each partition. With this clause included our window function now looks like this:
FUNCTION_NAME() OVER( PARTITION BY [var] ORDER BY [val] )
We can use the ORDER BY
clause to order values before performing an operation. This is particularly useful if we want to rank values using the RANK
function which assigns a numerical value to each distinct ORDER BY
value. This means it behaves slightly differently to the ROW_NUMBER
function because the RANK
function will assign the same rank to duplicated values. Check out the query below and compare the row outputs:
/* Comparing ROW_NUMBER and RANK using ORDER BY clause */
SELECT
"year"
,host_country
,first_place
,total_goals
,ROW_NUMBER() OVER( ORDER BY total_goals DESC ) AS row_num
,RANK() OVER( ORDER BY total_goals DESC ) AS row_rank
FROM world_cup_placings
;
|year|host_country |first_place |total_goals|row_num|row_rank|
|----|-------------|------------|-----------|-------|--------|
|2022|Qatar |Argentina |172 |1 |1 |
|1998|France |France |171 |2 |2 |
|2014|Brazil |Germany |171 |3 |2 |
|2018|Russia |France |169 |4 |4 |
|2002|Korea / Japan|Brazil |161 |5 |5 |
|2006|Germany |Italy |147 |6 |6 |
|1982|Spain |Italy |146 |7 |7 |
|2010|South Africa |Spain |145 |8 |8 |
|1994|United States|Brazil |141 |9 |9 |
|1954|Switerland |West Germany|140 |10 |10 |
|1986|Mexico |Argentina |132 |11 |11 |
|1958|Sweden |Brazil |126 |12 |12 |
|1990|Italy |West Germany|115 |13 |13 |
|1978|Argentina |Argentina |102 |14 |14 |
|1974|West Germany |West Germany|97 |15 |15 |
|1970|Mexico |Brazil |95 |16 |16 |
|1966|England |England |89 |17 |17 |
|1962|Chile |Brazil |89 |18 |17 |
|1950|Brazil |Uruguay |88 |19 |19 |
|1938|France |Italy |84 |20 |20 |
|1930|Uruguay |Uruguay |70 |21 |21 |
|1934|Italy |Italy |70 |22 |21 |
See the difference? If we look at the rows two and three, we see that both Brazil 2014 and France 1998 resulted in 171 goals. While the row_num
column assigns different values to these rows, the row_rank
columns has assigned the same rank.
Just note that, for the ROW_NUMBER
window function, I didn't actually need to specify ORDER BY total_goals DESC
, but I've done this to highlight the fact that the ROW_NUMBER
window function doesn't care about duplicated values; it simply numbers each row despite the ordering by total_goals
.
Okay, now let's try using the ORDER BY
and PARTITION BY
clause together to find in which year each team last won the World Cup. First, we'll use the first_place
column to partition the table and then sort the rows in descending order using the year
column. The following query shows you how to do this:
/* Rank winning teams by competition year to find most recent win */
SELECT
"year"
,host_country
,first_place
,RANK() OVER( PARTITION BY first_place ORDER BY "year" DESC ) AS row_rank
FROM world_cup_placings
;
|year|host_country |first_place |row_rank|
|----|-------------|------------|--------|
|2022|Qatar |Argentina |1 |
|1986|Mexico |Argentina |2 |
|1978|Argentina |Argentina |3 |
|2002|Korea / Japan|Brazil |1 |
|1994|United States|Brazil |2 |
|1970|Mexico |Brazil |3 |
|1962|Chile |Brazil |4 |
|1958|Sweden |Brazil |5 |
|1966|England |England |1 |
|2018|Russia |France |1 |
|1998|France |France |2 |
|2014|Brazil |Germany |1 |
|2006|Germany |Italy |1 |
|1982|Spain |Italy |2 |
|1938|France |Italy |3 |
|1934|Italy |Italy |4 |
|2010|South Africa |Spain |1 |
|1950|Brazil |Uruguay |1 |
|1930|Uruguay |Uruguay |2 |
|1990|Italy |West Germany|1 |
|1974|West Germany |West Germany|2 |
|1954|Switerland |West Germany|3 |
By sorting the competition years in descending order we ensure that the most recent year occupies the first row of each partition. Now, because there can only be one winner each year there are no duplicate values in the year
column. So, when we assign ranks to each partition, the first row will always have a rank of 1 which corresponds to the most recent win for each country.
This output, however, is a bit noisy and we have to read each row to find the ranks for each partition. We can tidy things up a bit by placing the above query within a sub-query and filtering out only the rows that have a rank of 1. Check out the query below to see this in action:
/* Filter only rows that have a rank of 1 */
SELECT
"year"
,first_place
FROM (
SELECT
"year"
,host_country
,first_place
,RANK() OVER( PARTITION BY first_place ORDER BY "year" DESC ) AS row_rank
FROM world_cup_placings
) AS subq
WHERE row_rank = 1
;
|year|first_place |
|----|------------|
|2022|Argentina |
|2002|Brazil |
|1966|England |
|2018|France |
|2014|Germany |
|2006|Italy |
|2010|Spain |
|1950|Uruguay |
|1990|West Germany|
Final Remarks
Window functions are remarkably versatile and are often more efficient when needing to compute values using only a subset of rows. This post just scratches the surface of what can be done using window functions and demonstrates only their basic functionality. Regardless, I hope you found something useful in this post. In a later post we'll build on these concepts and explore how window functions can be used to compute some more exotic measures.
Related Articles
Thanks for reading!
If you enjoyed this post and would like to stay up to date then please consider following me on Medium. This will ensure you don't miss out on any new content.
To get unlimited access to all content consider signing up for a Medium subscription.
You can also follow me on Twitter, LinkedIn, or check out my GitHub if that's more your thing.