Snowflake SQL Tutorial 46 - OVER() in Snowflake - Aggregate Without Losing Your Rows

Snowflake SQL · Episode 46

In this post we are going to cover OVER() in Snowflake. This is the first post of Level 5, and OVER() is where window functions begin.

A window function reads a group of rows and gives us one answer for every row in that group. An ordinary aggregate reads the same rows and replaces them with a single row. OVER() is the difference between those two things, and it is two empty brackets.

We will start with an aggregate we already know, add the brackets, and then build it up: comparing a row with its own group, several windows in one SELECT, what happens to NULLs, why WHERE will not have any of it, and percentage of total on a bigger table.

Every query below was run on a real Snowflake account, and every result you see is the output it gave back.

Episode 46 of the Snowflake SQL: Zero to Hero series on TechBrothersIT.

Let's create some sample data

Before we can window anything we need a table to window over. Most of this post runs against EMPLOYEES, the same eight-row table this course has used since Episode 1.

Copy this into a Snowflake worksheet and run it once.

Sample data
-- Creates the EMPLOYEES table used by every episode in this series.
-- Part of the shared sample-data script; run it once.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;

CREATE OR REPLACE TABLE EMPLOYEES (
    EMP_ID          INT,
    FIRST_NAME      VARCHAR,
    LAST_NAME       VARCHAR,
    EMAIL           VARCHAR,
    DEPT_ID         INT,
    MANAGER_ID      INT,
    SALARY          NUMBER(10,2),
    COMMISSION_PCT  NUMBER(4,2),
    HIRE_DATE       DATE
);

INSERT INTO EMPLOYEES VALUES
    (1, 'Aisha',  'Khan',      'aisha.khan@example.com',      10,    6, 118000.00, NULL,  '2019-03-11'),
    (2, 'Marcus', 'Reid',      'marcus.reid@example.com',     10,    6,  96500.00, NULL,  '2021-07-01'),
    (3, 'Lena',   'Fischer',   'lena.fischer@example.com',    20,    6, 104000.00, 0.05,  '2018-01-22'),
    (4, 'Tomas',  'Novak',     'tomas.novak@example.com',     20,    3,  87250.00, NULL,  '2022-09-05'),
    (5, 'Priya',  'Raman',     'priya.raman@example.com',     30,    6,  96500.00, 0.10,  '2020-11-17'),
    (6, 'Daniel', 'O''Connor', 'daniel.oconnor@example.com',  10, NULL, 131000.00, NULL,  '2016-05-30'),
    (7, 'Sofia',  'Marino',    'sofia.marino@example.com',    30,    5,  71400.00, 0.08,  '2023-02-13'),
    (8, 'Grace',  'Lin',       'grace.lin@example.com',     NULL,    6,  92000.00, NULL,  '2021-04-19');

Eight people and a salary each. One column is left empty for most of them, COMMISSION_PCT, and we will come back to it later on.

Let's start with an ordinary aggregate

Let's begin with the aggregate we already know. One function, one column, no GROUP BY.

Query
SELECT AVG(SALARY) AS AVG_SALARY FROM EMPLOYEES;
Result1 row · 1 column
AVG_SALARY
99581.25000000

That is the right answer to the question we asked. It is also everything else gone. We cannot see who sits above that figure and who sits below it, because the rows are no longer there. The aggregate used them up.

Now let's run the same function on the same column, with two empty brackets after it and nothing else changed.

Query
SELECT FIRST_NAME, DEPT_ID, SALARY,
       AVG(SALARY) OVER () AS AVG_SALARY
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 4 columns
FIRST_NAMEDEPT_IDSALARYAVG_SALARY
Aisha10118000.0099581.25000
Marcus1096500.0099581.25000
Lena20104000.0099581.25000
Tomas2087250.0099581.25000
Priya3096500.0099581.25000
Daniel10131000.0099581.25000
Sofia3071400.0099581.25000
GraceNULL92000.0099581.25000

Everybody is still here, and the average is sitting on every one of them. That is the whole idea of a window function. It reads a group of rows and answers once per row, instead of reading a group of rows and replacing them with one.

Now let's count the rows of both

Those two queries gave us the same average. What changed is how many rows came back. Rather than scroll between the two grids, let's ask Snowflake to count them for us in one result.

Query
SELECT
  (SELECT COUNT(*) FROM (SELECT AVG(SALARY) FROM EMPLOYEES))            AS PLAIN_ROWS,
  (SELECT COUNT(*) FROM (SELECT AVG(SALARY) OVER () FROM EMPLOYEES))    AS OVER_ROWS;
Result1 row · 2 columns
PLAIN_ROWSOVER_ROWS
18

1 against 8. Same table, same function, same column. OVER() is the switch between them.

What the brackets are for

The empty brackets are not decoration. They are the window, and empty means the whole result set is one group. Episode 47 puts PARTITION BY inside them, which makes the group smaller. Episode 48 puts ORDER BY inside them, which makes the group grow as you go down the rows. Everything in this level is a variation on what goes between those two brackets.

Now let's compare a row with its own group

Keeping the rows is useful because of what we can now do with them. We can put a row and its group's answer in the same expression and subtract one from the other.

Query
SELECT FIRST_NAME, SALARY,
       AVG(SALARY) OVER ()                    AS COMPANY_AVG,
       SALARY - AVG(SALARY) OVER ()           AS VS_AVG
FROM EMPLOYEES
ORDER BY VS_AVG DESC;
Result8 rows · 4 columns
FIRST_NAMESALARYCOMPANY_AVGVS_AVG
Daniel131000.0099581.2500031418.75000
Aisha118000.0099581.2500018418.75000
Lena104000.0099581.250004418.75000
Marcus96500.0099581.25000-3081.25000
Priya96500.0099581.25000-3081.25000
Grace92000.0099581.25000-7581.25000
Tomas87250.0099581.25000-12331.25000
Sofia71400.0099581.25000-28181.25000

Each row now carries the company average and its own distance from it. The sign tells us which side of the line that person is on. Sorting by that difference puts the furthest above at the top and the furthest below at the bottom. We could not write that query at all once an aggregate had thrown the rows away.

Can we do this with a subquery instead?

We can, and it is worth seeing. A scalar subquery in the SELECT list gives us the same figures.

Query
SELECT FIRST_NAME, SALARY,
       (SELECT AVG(SALARY) FROM EMPLOYEES)              AS COMPANY_AVG,
       SALARY - (SELECT AVG(SALARY) FROM EMPLOYEES)     AS VS_AVG
FROM EMPLOYEES
ORDER BY VS_AVG DESC;
Result8 rows · 4 columns
FIRST_NAMESALARYCOMPANY_AVGVS_AVG
Daniel131000.0099581.2500000031418.75000000
Aisha118000.0099581.2500000018418.75000000
Lena104000.0099581.250000004418.75000000
Marcus96500.0099581.25000000-3081.25000000
Priya96500.0099581.25000000-3081.25000000
Grace92000.0099581.25000000-7581.25000000
Tomas87250.0099581.25000000-12331.25000000
Sofia71400.0099581.25000000-28181.25000000

Same eight people, same order, same answers. Let's not compare two grids by eye though. Let's subtract one query from the other with MINUS, from Episode 35, which gives back the rows on the left that are not on the right.

Query
SELECT FIRST_NAME, SALARY, AVG(SALARY) OVER () AS COMPANY_AVG FROM EMPLOYEES
MINUS
SELECT FIRST_NAME, SALARY, (SELECT AVG(SALARY) FROM EMPLOYEES) FROM EMPLOYEES;
Result0 rows · 3 columns
FIRST_NAMESALARYCOMPANY_AVG

Nothing came back, so the two queries agree on all eight rows. The reason to prefer OVER() is what we write, not what it costs. The table is named once, the intent is on the line we are reading, and a second comparison does not mean a second subquery. Nothing on this page is timed and no performance claim is made anywhere.

Why this MINUS is empty and the one in Episode 47 is not

Look at the COMPANY_AVG column in the windowed grid and in the subquery grid. It is the same value printed to a different number of decimal places. The windowed version is computed at a fixed scale, and Snowflake casts the salary to NUMBER(25,5) to do it. The plain aggregate keeps more places than that. MINUS compares values and not text, and trailing zeros do not change a value, so these two still agree exactly. They stop agreeing only when the true answer does not finish inside that scale, which is what happens in Episode 47.

Now let's put several windows in one SELECT

We are not limited to one window function. Each one is worked out on its own, so four of them in one SELECT list is nothing unusual.

Query
SELECT FIRST_NAME, SALARY,
       COUNT(*)    OVER () AS HEADCOUNT,
       SUM(SALARY) OVER () AS PAYROLL,
       MIN(SALARY) OVER () AS LOWEST,
       MAX(SALARY) OVER () AS HIGHEST
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 6 columns · scroll →
FIRST_NAMESALARYHEADCOUNTPAYROLLLOWESTHIGHEST
Aisha118000.008796650.0071400.00131000.00
Marcus96500.008796650.0071400.00131000.00
Lena104000.008796650.0071400.00131000.00
Tomas87250.008796650.0071400.00131000.00
Priya96500.008796650.0071400.00131000.00
Daniel131000.008796650.0071400.00131000.00
Sofia71400.008796650.0071400.00131000.00
Grace92000.008796650.0071400.00131000.00

Every row carries the headcount, the total payroll, the lowest salary and the highest. Four aggregates, no GROUP BY, and no rows lost.

The grid also checks itself. Divide the PAYROLL column by the HEADCOUNT column and we get the average we started this post with, arrived at from a different pair of captured columns.

Let's see what NULLs do in a window

This is where people expect a surprise. There is not one, and that is worth showing precisely because the expectation is so common. COMMISSION_PCT is empty for most of this table, so let's count it and average it inside a window.

Query
SELECT FIRST_NAME, COMMISSION_PCT,
       COUNT(*)              OVER () AS ROWS_TOTAL,
       COUNT(COMMISSION_PCT) OVER () AS NON_NULL_COMMISSIONS,
       AVG(COMMISSION_PCT)   OVER () AS AVG_COMMISSION
FROM EMPLOYEES
ORDER BY EMP_ID;
Result8 rows · 5 columns
FIRST_NAMECOMMISSION_PCTROWS_TOTALNON_NULL_COMMISSIONSAVG_COMMISSION
AishaNULL830.07666
MarcusNULL830.07666
Lena0.05830.07666
TomasNULL830.07666
Priya0.10830.07666
DanielNULL830.07666
Sofia0.08830.07666
GraceNULL830.07666

Read the COMMISSION_PCT column down the grid. It is NULL for five of the eight people and present for three. COUNT(*) counts rows and reports eight. COUNT(COMMISSION_PCT) counts values and reports three. The average divides by three, not by eight.

Those are the ordinary aggregate rules, unchanged. Here they are again with no window function at all, so we can compare.

Query
SELECT COUNT(*) AS ROWS_TOTAL,
       COUNT(COMMISSION_PCT) AS NON_NULL_COMMISSIONS,
       AVG(COMMISSION_PCT) AS AVG_COMMISSION
FROM EMPLOYEES;
Result1 row · 3 columns
ROWS_TOTALNON_NULL_COMMISSIONSAVG_COMMISSION
830.07666667

One row instead of eight, and the same three answers. OVER() changes how many rows come back. It does not change what NULLs do.

Can we filter on it in WHERE?

This is the thing almost everybody tries within an hour of meeting OVER(). We already have a column that tells us who is above the company average, so keeping only those people should be one WHERE clause away. Let's try it.

Query — this one fails
SELECT FIRST_NAME, SALARY, AVG(SALARY) OVER () AS COMPANY_AVG
FROM EMPLOYEES
WHERE SALARY > COMPANY_AVG;
Query failed — Snowflake returned an error
000990 (42601): SQL compilation error: error line 3 at position 15
aggregate function alias 'COMPANY_AVG' cannot be used in the WHERE clause

That error is about the alias, and it is a rule we have met before. WHERE cannot see names we invented in the SELECT list. So let's write the window function out in full instead of referring to its alias.

Query — this one fails too
SELECT FIRST_NAME, SALARY
FROM EMPLOYEES
WHERE SALARY > AVG(SALARY) OVER ();
Query failed — Snowflake returned an error
002063 (42601): SQL compilation error:
Window function [AVG(CAST(EMPLOYEES.SALARY AS NUMBER(25,5))) OVER ()] appears outside of SELECT, QUALIFY, and ORDER BY clauses.

A different error, and this one states the actual rule. A window function may appear in SELECT, in QUALIFY and in ORDER BY, and nowhere else. WHERE has already finished choosing rows by the time a window function runs. That is the only order that makes sense. The window is computed over the rows that survived the filter, so it cannot also be the filter.

So let's compute it first, then filter

We put the whole query in brackets, give it a name, and filter from outside. The window function has finished by the time the outer WHERE runs, so the outer query is filtering on an ordinary column.

Query
SELECT FIRST_NAME, SALARY, COMPANY_AVG
FROM (SELECT FIRST_NAME, SALARY, AVG(SALARY) OVER () AS COMPANY_AVG
      FROM EMPLOYEES) W
WHERE SALARY > COMPANY_AVG
ORDER BY SALARY DESC;
Result3 rows · 3 columns
FIRST_NAMESALARYCOMPANY_AVG
Daniel131000.0099581.25000
Aisha118000.0099581.25000
Lena104000.0099581.25000

Three rows out of eight. And have a look at the average beside them. It is still the average of all eight people, not of the three that survived. It was computed before the filter, which is exactly what we wanted.

The one-clause version

QUALIFY, from Episode 23, does this in one clause instead of a wrapper. It filters on the result of a window function in the same query that computes it. The reason it exists is the rule we just read. The reason to learn the wrapper first is that QUALIFY is a Snowflake convenience, while wrapping works everywhere.

Let's try percentage of total on a bigger table

One more, on a bigger table. Percentage of total is the pattern we will write most often, and it needs both halves of a window function in one expression: the row's own value and the total across all the rows.

So we need a second table. Here is the script for it.

Sample data
-- Creates the ORDERS table the last section of this page uses.
-- Part of the shared sample-data script; run it once.
USE DATABASE DEMO_DB;
USE SCHEMA PUBLIC;

CREATE OR REPLACE TABLE ORDERS (
    ORDER_ID     INT,
    CUSTOMER_ID  INT,
    ORDER_DATE   DATE,
    SHIP_DATE    DATE,
    STATUS       VARCHAR,
    AMOUNT       NUMBER(10,2),
    QUANTITY     INT
);

INSERT INTO ORDERS VALUES
    (1001, 101, '2023-01-12', '2023-01-15', 'SHIPPED',   4200.00,  12),
    (1002, 102, '2023-01-28', '2023-02-02', 'SHIPPED',    890.50,   3),
    (1003, 101, '2023-02-14', '2023-02-16', 'SHIPPED',   6150.00,  20),
    (1004, 103, '2023-02-27', NULL,         'CANCELLED', 1500.00,   5),
    (1005, 104, '2023-03-09', '2023-03-14', 'SHIPPED',   2310.75,   8),
    (1006, 101, '2023-03-22', '2023-03-24', 'SHIPPED',   5020.00,  16),
    (1007, 105, '2023-04-05', '2023-04-11', 'SHIPPED',   7890.00,  25),
    (1008, 102, '2023-04-19', '2023-04-23', 'SHIPPED',   1120.00,   4),
    (1009, 103, '2023-05-02', '2023-05-06', 'SHIPPED',   3340.25,  11),
    (1010, 106, '2023-05-21', NULL,         'PENDING',    640.00,   2),
    (1011, 101, '2023-06-08', '2023-06-10', 'SHIPPED',   8100.00,  27),
    (1012, 104, '2023-06-25', '2023-06-30', 'SHIPPED',   1975.50,   7),
    (1013, 105, '2023-07-11', '2023-07-15', 'SHIPPED',   6420.00,  21),
    (1014, 102, '2023-07-29', '2023-08-03', 'RETURNED',   750.00,   3),
    (1015, 107, '2023-08-14', '2023-08-17', 'SHIPPED',   9250.00,  30),
    (1016, 103, '2023-08-30', '2023-09-04', 'SHIPPED',   2860.00,   9),
    (1017, 101, '2023-09-16', '2023-09-19', 'SHIPPED',   5540.00,  18),
    (1018, 106, '2023-10-03', '2023-10-08', 'SHIPPED',   1430.00,   5),
    (1019, 105, '2023-10-22', '2023-10-26', 'SHIPPED',   7015.00,  23),
    (1020, 107, '2023-11-07', '2023-11-10', 'SHIPPED',  10400.00,  34),
    (1021, 102, '2023-11-24', '2023-11-29', 'SHIPPED',    980.00,   4),
    (1022, 104, '2023-12-09', NULL,         'CANCELLED', 2250.00,   8),
    (1023, 101, '2023-12-27', '2023-12-30', 'SHIPPED',   6730.00,  22),
    (1024, 103, '2024-01-15', '2024-01-18', 'SHIPPED',   3910.00,  13),
    (1025, 107, '2024-01-31', '2024-02-05', 'SHIPPED',  11200.00,  36),
    (1026, 105, '2024-02-17', '2024-02-21', 'SHIPPED',   8340.00,  27),
    (1027, 106, '2024-03-04', '2024-03-09', 'SHIPPED',   1690.00,   6),
    (1028, 102, '2024-03-21', '2024-03-25', 'SHIPPED',   1240.00,   5),
    (1029, 101, '2024-04-06', '2024-04-09', 'SHIPPED',   7460.00,  24),
    (1030, 109, '2024-04-23', '2024-04-28', 'SHIPPED',   5180.00,  17),
    (1031, 103, '2024-05-10', '2024-05-14', 'SHIPPED',   4025.00,  14),
    (1032, 107, '2024-05-28', '2024-06-01', 'SHIPPED',  12850.00,  41),
    (1033, 105, '2024-06-13', NULL,         'PENDING',   6900.00,  22),
    (1034, 109, '2024-06-30', '2024-07-04', 'SHIPPED',   4760.00,  16),
    (1035, 101, '2024-07-18', '2024-07-21', 'SHIPPED',   8820.00,  29),
    (1036, 104, '2024-08-02', '2024-08-07', 'SHIPPED',   2540.00,   9),
    (1037, 107, '2024-08-25', '2024-08-29', 'SHIPPED',  13100.00,  43),
    (1038, 109, '2024-09-11', '2024-09-15', 'SHIPPED',   5930.00,  19),
    (1039, 102, '2024-09-29', '2024-10-03', 'RETURNED',  1080.00,   4),
    (1040, 101, '2024-10-16', '2024-10-19', 'SHIPPED',   9640.00,  31);

-- One deliberate NULL amount, inserted separately so it is easy to see why it
-- is there. It is the row the percentage-of-total section has to explain.
INSERT INTO ORDERS VALUES
    (1041, 106, '2024-11-05', NULL, 'PENDING', NULL, 3);

That script inserts 41 rows. The last one is on its own at the end because its AMOUNT is NULL on purpose.

Now the query. The amount, the total over all the rows, and the amount divided by that total.

Query — LIMIT 10 so it fits on a screen
SELECT ORDER_ID, CUSTOMER_ID, AMOUNT,
       SUM(AMOUNT) OVER () AS ALL_ORDERS_TOTAL,
       AMOUNT / SUM(AMOUNT) OVER () * 100 AS PCT_OF_TOTAL
FROM ORDERS
ORDER BY AMOUNT DESC
LIMIT 10;
Result10 rows · 5 columns
ORDER_IDCUSTOMER_IDAMOUNTALL_ORDERS_TOTALPCT_OF_TOTAL
1041106NULL205427.00NULL
103710713100.00205427.006.37696100
103210712850.00205427.006.25526300
102510711200.00205427.005.45205800
102010710400.00205427.005.06262600
10401019640.00205427.004.69266500
10151079250.00205427.004.50281600
10351018820.00205427.004.29349600
10261058340.00205427.004.05983600
10111018100.00205427.003.94300700

Every row carries the same grand total and its own share of it. Ten rows are shown because the query asks for ten. The total in that column is computed over all 41.

Two things about the first row, which is the one that would otherwise make us doubt the grid. Its AMOUNT is NULL, so its percentage is NULL too, because arithmetic on an unknown value is unknown. And it is at the top of a descending sort because Snowflake puts NULLs first when we sort descending, which was Episode 7. So this grid is the first ten rows of that sort: the NULL row, then the nine largest amounts.

The total is worth checking against the table itself, with no window function anywhere in the query.

Query
SELECT COUNT(*) AS N_ORDERS, SUM(AMOUNT) AS TOTAL FROM ORDERS;
Result1 row · 2 columns
N_ORDERSTOTAL
41205427.00

The same total, over the 41 rows the setup script inserted. The window function computed the figure the plain aggregate computes. It just handed a copy to every row instead of keeping it for itself.

Key points

  • OVER() turns an aggregate into a window function. Same function, same column, same table — the difference is that the rows survive.
  • A plain AVG on this table returns 1 row; the same AVG with OVER() returns 8. That row count is the whole feature.
  • Empty brackets mean one group containing the entire result set. PARTITION BY and ORDER BY go inside them, and that is the rest of this level.
  • Because the rows survive, you can compare a row to its group in one expression — the row's value and the group's answer are both available on the same line.
  • A scalar subquery gives the same answers. OVER() wins on what you write: the table is named once and a second comparison does not need a second subquery.
  • NULLs are untouched by any of this. COUNT(*) counts rows, COUNT(column) counts values, and an average divides by the number of values it found.
  • A window function may only appear in SELECT, QUALIFY and ORDER BY. Snowflake's own error message says so.
  • WHERE cannot filter on a window function because WHERE runs first. Wrap the query and filter outside, or use QUALIFY from Episode 23.
  • In a wrapped query the window was computed before the filter, so the group's answer still describes every row — not only the rows that survived.
Next in this series

In the next post we will put PARTITION BY inside those brackets. Empty brackets mean one group, the whole table. PARTITION BY gives us the same groups GROUP BY would make, without losing a single row — including a MINUS against GROUP BY that was supposed to return nothing and did not.

Every query and every result grid on this page was captured from a live Snowflake account (version 10.34.101) running the demo script for this episode, including both errors and the empty MINUS result. The EMPLOYEES and ORDERS data is invented demo data created by the setup scripts above. No performance or benchmark claims are made anywhere in this series. Clause behaviour follows the Snowflake window function documentation.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.