SQL ウィンドウ関数の基本を超えた実務ビジネス問題の解決法
この記事は、SQL ウィンドウ関数の基本的な使用法を超え、ビジネス現場で頻出する実務課題を解決するための具体的なパターンと戦略を紹介している。
キーポイント
ウィンドウ関数の本質的価値
単なる集計ではなく、個々の行データを保持したまま累積値や比較指標を算出できる点が、従来の GROUP BY とは異なる最大の利点である。
ランニング総額の計算パターン
財務分析などで頻繁に利用される月次売上累計の算出において、各期間の値と累積合計を同時に出力する標準的な解決策として紹介されている。
実践的な学習リソース
記事で提示された事例は実際の面接問題であり、StrataScratch などのプラットフォームで練習することでスキルを向上させられることが強調されている。
デフォルトのウィンドウフレームの挙動
明示的なフレーム句を指定しない場合、ORDER BY 付きの窓関数は 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW' をデフォルトとして使用し、現在の行までの累積計算を実現します。
集計と窓関数の組み合わせによる累積合計
サブクエリで月ごとの収益を集計した後、外部クエリの窓関数を用いてその集計値を時系列順に足し合わせることで、月次推移に基づく累積収益を算出できます。
Gaps and Islands パターンとセッション化
連続する同じ条件の行を「島 (island)」、その間の空白を「ギャップ (gap)」と呼び、ユーザーのイベントストリームを一定時間以内の活動単位としてグループ化するセッション化に広く応用されます。
SQL での検出ロジック
LAG() または LEAD() で境界をフラグ付けし、そのフラグを累積和 (SUM) 関数でストリーク ID に変換することで、連続する活動期間を特定します。
影響分析・編集コメントを表示
影響分析
この記事は、データエンジニアやアナリストにとって、SQL の基礎知識を実戦レベルへ引き上げるための重要な指針となる。特に、集計処理における行の粒度維持という概念を理解することで、より複雑な分析クエリの構築能力が向上し、ビジネスインサイトの抽出効率が高まることが期待される。
編集コメント
AI や生成モデルの話題ではないが、データサイエンスやエンジニアリングの基礎体力を高める上で非常に実用的な内容であり、現場で即戦力となるスキル向上に寄与する。
image**
# イントロダクション
多くの皆さんは SQL のウィンドウ関数を使っていますが、その表面をなぞっているに過ぎません。ここでは ROW_NUMBER() を使い、あちらでは SUM() OVER() を使うといった具合です。ウィンドウ関数の真の可能性が最も発揮されるのは、より難しい問題に応用したときです。私は、ウィンドウ関数が最も有用となる 4 つのパターンを詳しく解説していきます。

これらの例はすべて、StrataScratch で練習できる実際の面接問題です。**
# 累積合計の計算
**
累積合計(Running Totals)の計算は、ウィンドウ関数のビジネス利用において最も一般的なケースの一つです。財務担当者はこれを非常に好みます!これは月ごとの累積売上高を追跡するために使用され、それによって年間売上目標に対する現在の達成度を簡単に算出できます。

これがウィンドウ関数の問題となる理由は、通常、期間ごとの値と累積合計の両方を同じ出力結果に含める必要があるからです。SUM() と GROUP BY を使用することはできません。なぜなら、それらは個々の行を集約(結合)してしまうためです。したがって、明白な解決策はウィンドウ関数を使用すること、つまり SUM() OVER() です。
// 例:時間経過に伴う売上高の計算
この Amazon の質問 は本来、3 ヶ月間のローリング平均を計算するよう求めています。しかしここではそれを無視し、各月の累積売上高を計算します。
データ:こちらが amazon_purchases テーブルのプレビューです。
user_id
created_at
purchase_amt
10
2020-01-01
3742
11
2020-01-04
1290
12
2020-01-07
4249
...
...
...
109
2020-10-24
1749
コード: 内部クエリは TO_CHAR() を使用して日付を YYYY-MM フォーマットに変換し、WHERE purchase_amt > 0 で返品を除外しながら月ごとの売上高を集計します。
外部クエリでは、上記で集計した月ごとの合計に対してウィンドウ関数(window function)を適用しています。OVER() 内で明示的なフレーム句(frame clause)は指定していません(意図的に)。そのため、ウィンドウ関数はデフォルトの「RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW」が適用されます。これは、現在の行(つまり月)より前のすべての行を対象とするウィンドウを意味します。言い換えれば、累積和は「過去のすべての月+現在月」となります。当然のことながら、これはまさに教科書的な累積和の定義です。
SELECT t.month,
t.monthly_revenue,
SUM(t.monthly_revenue) OVER(ORDER BY t.month) AS cumulative_revenue
FROM (
SELECT TO_CHAR(created_at::DATE, 'YYYY-MM') AS month,
SUM(purchase_amt) AS monthly_revenue
FROM amazon_purchases
WHERE purchase_amt > 0
GROUP BY TO_CHAR(created_at::date, 'YYYY-MM')
ORDER BY TO_CHAR(created_at::date, 'YYYY-MM')
) t
ORDER BY t.month ASC;
出力:
month
monthly_revenue
cumulative_revenue
2020-01
26292
26292
2020-02
20695
46987
2020-03
29620
76607
...
...
...
2020-10
15310
239869
# Gaps and Islands (Sessionization)
**
このパターンも、累積合計と同様に順序付きデータを扱いますが、異なるウィンドウ関数(window functions)を使用します。
アイランド(island)とは、同じ条件を満たす行の連続した列のことで、例えば連続する毎日のログインなどが該当します。ギャップ(gap)は、そのアイランド間の空間を指します**。
このパターンの最も一般的な実世界での応用例の一つがセッション化(sessionization)です。これは、生イベントストリームをセッション単位にグループ化する処理です。セッションは通常、同一ユーザーからの一連のイベントであり、連続するイベント間のギャップがあるタイムアウト時間(ウェブ分析では 30 分が標準)を超えないものとして定義されます。
セッション化は、プロダクトエンジニアリングおよびデータエンジニアリングで一般的に適用されています。生イベントストリームを意味のある活動単位にグループ化する必要があるあらゆる場面で利用されます。
**

SQL における古典的な検出手法は、以下の 2 つのステップから構成されています:
- LAG() または LEAD() — 各行を前後の行と比較し、新しい連続記録(ストリーク)が始まる箇所をフラグ付けするために使用します。
- SUM(flag) OVER (PARTITION BY user ORDER BY date) — フラグを累積してストリーク ID に変換します。これはストリーク内では一定値を保ち、境界部でインクリメントされます。
// 例:ユーザーの連続記録(ストリーク)の特定
LinkedIn や Meta の面接で出題された質問では、2022 年 8 月 10 日までのプラットフォーム訪問における最長連続記録を持つ上位 3 人のユーザーを見つけるよう求められています。ストリーク長が同じユーザーが複数いる場合も、そのすべてのユーザーを出力してください。
データ: テーブル名は user_streaks です。
user_id
date_visited
u001
2022-08-01
u001
2022-08-01
u004
2022-08-01
...
...
u005
2022-08-11
コード: クエリは長くなりますが、CTE(共通テーブル式)に整理されているため、流れを追うのは容易です。
- unique_visits: 重複する訪問記録を削除し、データを 2022 年 8 月 10 日までに制限します。
- streak_flags: LAG() を使用して各ユーザーの直前の訪問日を取得し、ギャップが 1 日の場合はストリークの継続(フラグ 0)、それ以外の場合は新しいストリーク開始(フラグ 1)として行をフラグ付けします。
- streak_ids: 累積 SUM() を用いてフラグをストリークグループ ID に変換します。
- streak_lengths: 各ストリークの日数をカウントします。
- longest_per_user: 各ユーザーの最長ストリークのみを残します。
- ranked_lengths: 一意なストリーク長の順位付けを行います。
- top_lengths: ストリーク長の上位 3 つの値を特定します。
最終的な SELECT ステートメントはすべての要素を結びつけ、上位 3 つの連続記録を持つすべてのユーザーと、それぞれの記録の日数長を表示します。
WITH unique_visits AS (
SELECT DISTINCT user_id, date_visited
FROM user_streaks
WHERE date_visited <= DATE '2022-08-10'),
streak_flags AS (
SELECT *,
CASE
WHEN date_visited
- LAG(date_visited) OVER (PARTITION BY user_id ORDER BY date_visited) = 1
THEN 0
ELSE 1
END AS new_streak
FROM unique_visits),
streak_ids AS (
SELECT *,
SUM(new_streak) OVER (PARTITION BY user_id ORDER BY date_visited) AS streak_id
FROM streak_flags),
streak_lengths AS (
SELECT user_id,
streak_id,
COUNT(*) AS streak_length
FROM streak_ids
GROUP BY user_id, streak_id),
longest_per_user AS (
SELECT user_id,
MAX(streak_length) AS streak_length
FROM streak_lengths
GROUP BY user_id),
ranked_lengths AS (
SELECT DISTINCT
streak_length,
DENSE_RANK() OVER (ORDER BY streak_length DESC) AS len_rank
FROM longest_per_user),
top_lengths AS (
SELECT streak_length
FROM ranked_lengths
WHERE len_rank <= 3)
SELECT u.user_id,
u.streak_length
FROM longest_per_user u
JOIN top_lengths t USING (streak_length)
ORDER BY u.streak_length DESC, u.user_id;
出力:
user_id
streak_length
u004
10
u005
10
u003
5
u001
4
u006
4
# コホート分析
コホートとは、最初のイベント(例えば初回購入、初回ログイン、または初回購読日など)を共有するユーザーのグループのことです。コホート分析はリテンションレポートの基盤であり、最初のイベント後に何人のユーザーが戻ってきたかという問いに答えるものです。
**

コホート分析の鍵は、ユーザーのアクティビティ履歴からコホートのアンカー(基準点)を見つけ出し、その後のすべての活動をそれに対して測定することにあります。
これを SQL で実現するには、主に 3 つのウィンドウ関数のアプローチに帰着します:
- MIN(event_time) OVER (PARTITION BY user_id) — アンカーが日付である場合によく使われる最も一般的なパターンです。
- FIRST_VALUE(attribute) OVER (PARTITION BY user_id ORDER BY event_time) — アンカー値そのもの(例えば最初の商社や最初の商品カテゴリなど)が必要な場合に使用されます。
- ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) = 1 — 最初のイベントを別行として抽出し、完全な履歴と結合したい場合、またはすべての行にブロードキャストしたくない場合に使用されます。
// 例:初回注文数のカウント
こちらが DoorDash の質問 です。この問題では、各マーチャントが獲得した注文数と、顧客の視点から見た初回注文数を計算する必要があります。また、一度も注文を受けないマーチャントは除外してください。
データ: 最初のテーブル名は order_details です。
id
customer_id
merchant_id
order_timestamp
n_items
total_amount_earned
8
1049
6
2022-01-14 01:00:28
5
16.3
7
1049
5
2022-01-14 11:50:29
4
2.16
22
1049
1
2022-01-14 22:46:54
8
2.63
...
...
...
...
...
...
39
1060
1
2022-01-16 22:27:30
11
15.41
2 つ目のテーブルは merchant_details です。
id
name
category
zipcode
1
Treehouse Pizza
american
92507
2
Thai Lion
asian
90017
3
Meal Raven
fast food
95204
...
...
...
...
7
Taste Of Gyros
mediterranean
94789
コード: 最初の CTE(共通テーブル式)でコホートロジックが実行されます。FIRST_VALUE() ウィンドウ関数を使用して、各顧客の最初のお取引からマーチャントを特定し、その顧客の注文履歴のすべての行に付与します。結果として得られるテーブルでは、すべての注文に「その顧客が最初に利用したマーチャント」を示すラベルが付与されます。
2 つ目の CTE では、LEFT JOIN を使用してラベルを完全な注文履歴に結合し、注文を受け取ったことがあっても誰かの最初の商売相手になったことがない商人も結果に表示されるようにしています。COUNT() と DISTINCT を使用して、その商人が顧客の最初の商売相手であった顧客のみをカウントします — これがコホートサイズです。別の COUNT() で総注文数を取得できます。LEFT JOIN が first_order と結合することで重複する注文行が発生する可能性があるため、ここでは DISTINCT も必要です — first_order は顧客ではなく注文ごとに 1 行ずつ保持するため、order_details の単一の注文が、同じ顧客に対して first_order の複数の行と一致し、DISTINCT を使用しないとカウントが増大してしまいます。
最終的な SELECT では、number_of_customers CTE を merchant_details に結合して商人の名前を取得します。
WITH first_order AS (
SELECT customer_id,
FIRST_VALUE(merchant_id) OVER(PARTITION BY customer_id ORDER BY order_timestamp) AS first_merchant
FROM order_details),
number_of_customers AS (
SELECT merchant_id,
COUNT(DISTINCT f.customer_id) AS first_time_orders,
COUNT(DISTINCT id) AS total_number_of_orders
FROM order_details d
LEFT JOIN first_order f ON d.merchant_id = f.first_merchant
GROUP BY 1)
SELECT name,
total_number_of_orders,
first_time_orders
FROM number_of_customers
JOIN merchant_details ON number_of_customers.merchant_id = merchant_details.id;
出力:
name
total_number_of_orders
first_time_orders
Treehouse Pizza
8
1
Thai Lion
14
7
Meal Raven
12
0
Burger A1
4
0
Sushi Bay
7
3
Tacos You
7
1
# Percentile and Ranking Analysis
**
集計関数は平均値を教えてくれます。ウィンドウベースのランキング関数は分布を示し、興味深いビジネス上の質問はまさにこの分布の中にあります。あなたの 90 パーセンタイルの注文金額が異常に高いことはありますか?これは少数の大規模な購入者が収益を歪めていることを示唆していますか?販売担当者の下位 25% は中央値の近くに集まっているのか、それとも遥かに低い位置にあるのでしょうか?
NTILE(n) は行を n 個のおおよそ等しいバケットに分割します。PERCENT_RANK() は各行のランクを 0 から 1 の間の値として表現します。CUME_DIST() は、現在の行よりも小さいまたは等しい値を持つ行の割合を示します。そして PERCENTILE_CONT() は、特定のパーセンタイル閾値における実際の値を計算します — これは結果セット内での順位付けではなく、動的なカットオフに基づいてフィルタリングを行いたい場合に役立ちます。

// Example: Identifying Top Percentile Fraud
これは Google と Netflix によるもの です。各州で最も疑わしい請求を特定することを求めています。仮定は、各州の上位 5% の請求が潜在的に詐欺であるということです。
データ:** テーブル名は fraud_score です。
policy_num
state
claim_cost
fraud_score
ABCD1001
CA
4113
0.61
ABCD1002
CA
3946
0.16
ABCD1003
CA
4335
0.01
...
...
...
...
ABCD1400
TX
3922
0.59
Code: コード内では、PERCENTILE_CONT(0.95) が各州内の不正スコアの 95 パーセンタイルにおける補間値を計算します。
以下の SELECT ステートメントでは、CTE を元のテーブルと結合することで、すべての請求がそれぞれの州の閾値と比較されます。その値以上である請求が対象となります。
WITH state_percentiles AS (
SELECT state,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY fraud_score) AS p95
FROM fraud_score
GROUP BY state)
SELECT f.policy_num,
f.state,
f.claim_cost,
f.fraud_score
FROM fraud_score f
JOIN state_percentiles sp
ON f.state = sp.state
WHERE f.fraud_score >= sp.p95;
Output:
policy_num
state
claim_cost
fraud_score
ABCD1016
CA
1639
0.96
ABCD1021
CA
4898
0.95
ABCD1027
CA
2663
0.99
...
...
...
...
ABCD1398
TX
3191
0.98
# Conclusion
**
これら 4 つのパターンには共通する哲学があります。すなわち、可能な限り単一のパスでデータベース内で処理を行い、SQL ウィンドウ指定の表現力を最大限に活用することです。
ウィンドウ関数が真に強力である理由は、特定の関数単体の能力にあるのではなく、その組み合わせ可能性にあります。CTE を連鎖させたり、同じ SELECT 文内で複数のウィンドウ関数を適用したりして、ビジネス問題そのものの記述のように読みやすい複雑な分析ロジックを構築できる点です。
Nate Rosidi はデータサイエンティストであり、製品戦略にも携わっています。また、分析を教える非常勤講師でもあり、トップ企業からの実際の面接質問を通じてデータサイエンティストの準備をサポートするプラットフォーム「StrataScratch」の創設者です。Nate はキャリア市場における最新動向について執筆し、面接に関するアドバイスを提供し、データサイエンスプロジェクトを紹介し、SQL 関連のあらゆるトピックをカバーしています。
原文を表示

**
# Introduction
Most of you use SQL window functions, but you're only scratching the surface — a ROW_NUMBER() here, a SUM() OVER() there. The window functions' real potential is revealed when you apply them to harder problems. I will walk you through four patterns that show window functions at their most useful.

The examples are all real interview questions you can practice on StrataScratch**.
# Running Totals
**
Calculating running totals is one of the most common business uses of window functions. The finance people absolutely love it! It is used to track cumulative monthly revenue, which then easily moves into calculating where you're at compared to the annual revenue target.

What makes this a window function problem is that, typically, you should include both the per-period value and the accumulating total in the same output. You can't use GROUP BY with SUM(), because that collapses individual rows. So, the obvious solution is using a window function, i.e., SUM() OVER().
// Example: Calculating Revenue Over Time
This Amazon question originally asks you to calculate the 3-month rolling average. However, we'll disregard that and calculate the cumulative revenue for each month.
Data:** Here's the amazon_purchases table preview.
user_id
created_at
purchase_amt
10
2020-01-01
3742
11
2020-01-04
1290
12
2020-01-07
4249
...
...
...
109
2020-10-24
1749
Code: The inner query turns dates into YYYY-MM format using TO_CHAR() and aggregates monthly revenue, filtering out returns with WHERE purchase_amt > 0.
The outer query applies the window function over those monthly totals we calculated. I don't specify an explicit frame clause (intentionally) in OVER(), so the window function defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That means the window is all rows preceding the current row, i.e., the month. In other words, the cumulative sum is: all previous months + the current month. Not surprisingly, that *is* a textbook definition of a cumulative sum.
SELECT t.month,
t.monthly_revenue,
SUM(t.monthly_revenue) OVER(ORDER BY t.month) AS cumulative_revenue
FROM (
SELECT TO_CHAR(created_at::DATE, 'YYYY-MM') AS month,
SUM(purchase_amt) AS monthly_revenue
FROM amazon_purchases
WHERE purchase_amt > 0
GROUP BY TO_CHAR(created_at::date, 'YYYY-MM')
ORDER BY TO_CHAR(created_at::date, 'YYYY-MM')
) t
ORDER BY t.month ASC;Output:
month
monthly_revenue
cumulative_revenue
2020-01
26292
26292
2020-02
20695
46987
2020-03
29620
76607
...
...
...
2020-10
15310
239869
# Gaps and Islands (Sessionization)
**
This pattern, too, involves sequential data, just like running totals, but it employs different window functions.
An island is a run of rows with the same condition, e.g., consecutive daily logins. A gap is the space between islands**.
One of the most common real-world applications of this pattern is sessionization — grouping a raw event stream into sessions. A session is typically defined as a sequence of events from the same user where no gap between consecutive events exceeds some timeout (30 minutes is the web analytics standard).
Sessionization is commonly applied in product and data engineering. It is used anywhere you need to group raw event streams into meaningful units of activity.
**

The classic detection in SQL consists of two steps:
- LAG() or LEAD() — to compare each row to the one before or after it, and flag where a new streak starts.
- SUM(flag) OVER (PARTITION BY user ORDER BY date) — to accumulate flags into a streak ID, as it stays flat inside a streak and increments at every boundary.
// Example: Finding User Streaks
The question from LinkedIn and Meta interviews asks you to find the top three users with the longest platform visit streak until August 10, 2022. You should output all users with the top three longest streaks, if there is more than one user per streak length.
Data:** The table is user_streaks.
user_id
date_visited
u001
2022-08-01
u001
2022-08-01
u004
2022-08-01
...
...
u005
2022-08-11
Code: The query is long, but it's neatly structured into CTEs, so it's easy to follow.
- unique_visits: Removes duplicate visit records and caps the data at August 10, 2022.
- streak_flags: Uses LAG() to get the previous visit date per user and flags the row as 0 (a streak continuation if the gap is 1 day) or 1 (a new streak start for any other gap).
- streak_ids: Converts flags into streak group IDs using a cumulative SUM().
- streak_lengths: Counts days per streak.
- longest_per_user: Keeps only each user's longest streak.
- ranked_lengths: Ranks distinct streak lengths.
- top_lengths: Finds the top 3 streak-length values.
The final SELECT ties everything together: it shows all users with the top three streaks and their respective streak lengths in days.
WITH unique_visits AS (
SELECT DISTINCT user_id, date_visited
FROM user_streaks
WHERE date_visited <= DATE '2022-08-10'),
streak_flags AS (
SELECT *,
CASE
WHEN date_visited
- LAG(date_visited) OVER (PARTITION BY user_id ORDER BY date_visited) = 1
THEN 0
ELSE 1
END AS new_streak
FROM unique_visits),
streak_ids AS (
SELECT *,
SUM(new_streak) OVER (PARTITION BY user_id ORDER BY date_visited) AS streak_id
FROM streak_flags),
streak_lengths AS (
SELECT user_id,
streak_id,
COUNT(*) AS streak_length
FROM streak_ids
GROUP BY user_id, streak_id),
longest_per_user AS (
SELECT user_id,
MAX(streak_length) AS streak_length
FROM streak_lengths
GROUP BY user_id),
ranked_lengths AS (
SELECT DISTINCT
streak_length,
DENSE_RANK() OVER (ORDER BY streak_length DESC) AS len_rank
FROM longest_per_user),
top_lengths AS (
SELECT streak_length
FROM ranked_lengths
WHERE len_rank <= 3)
SELECT u.user_id,
u.streak_length
FROM longest_per_user u
JOIN top_lengths t USING (streak_length)
ORDER BY u.streak_length DESC, u.user_id;Output:
user_id
streak_length
u004
10
u005
10
u003
5
u001
4
u006
4
# Cohort Analysis
**
A cohort is a group of users who share a starting event, for example, a first purchase, first login, or first subscription date. Analyzing cohorts is the foundation of retention reporting, as it answers the question of how many users came back after the starting event**.
**

The key thing in cohort analysis is finding the cohort anchor** in the user's activity history so that you can measure all subsequent activity against it.
Doing that in SQL boils down to three main window function approaches:
- MIN(event_time) OVER (PARTITION BY user_id) — the most common pattern when the anchor is a date.
- FIRST_VALUE(attribute) OVER (PARTITION BY user_id ORDER BY event_time) — used when you need the anchor value itself, e.g., the first merchant or first product category.
- ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) = 1 — used when you want to isolate the first event as a separate row and join it back to the full history rather than broadcasting it across all rows.
// Example: Counting First-Time Orders
Here's a DoorDash question. It requires you to calculate the number of orders and first-time orders (from a customer's perspective) each merchant has had. You should also exclude merchants that have not received any orders.
Data: The first table is named order_details.
id
customer_id
merchant_id
order_timestamp
n_items
total_amount_earned
8
1049
6
2022-01-14 01:00:28
5
16.3
7
1049
5
2022-01-14 11:50:29
4
2.16
22
1049
1
2022-01-14 22:46:54
8
2.63
...
...
...
...
...
...
39
1060
1
2022-01-16 22:27:30
11
15.41
The second table is merchant_details.
id
name
category
zipcode
1
Treehouse Pizza
american
92507
2
Thai Lion
asian
90017
3
Meal Raven
fast food
95204
...
...
...
...
7
Taste Of Gyros
mediterranean
94789
Code: The first CTE is where the cohort logic happens. I use the FIRST_VALUE() window function to attach the merchant from each customer's earliest order to every row in their order history. The result is a table where every order carries the label of which merchant that customer started with.
In the second CTE, I join the labels back to the full order history using a LEFT JOIN to ensure that merchants who received orders but were never anyone's first merchant still appear in the result. We use COUNT() and DISTINCT to count only the customers for whom that merchant was their first — that's your cohort size. With another COUNT(), you get the total number of orders. DISTINCT is required here, too, because the LEFT JOIN with first_order can produce duplicate order rows — since first_order retains one row per order (not per customer), a single order in order_details can match multiple rows in first_order for the same customer, inflating the count without it.
In the final SELECT, we join the number_of_customers CTE with merchant_details to bring in the merchant names.
WITH first_order AS (
SELECT customer_id,
FIRST_VALUE(merchant_id) OVER(PARTITION BY customer_id ORDER BY order_timestamp) AS first_merchant
FROM order_details),
number_of_customers AS (
SELECT merchant_id,
COUNT(DISTINCT f.customer_id) AS first_time_orders,
COUNT(DISTINCT id) AS total_number_of_orders
FROM order_details d
LEFT JOIN first_order f ON d.merchant_id = f.first_merchant
GROUP BY 1)
SELECT name,
total_number_of_orders,
first_time_orders
FROM number_of_customers
JOIN merchant_details ON number_of_customers.merchant_id = merchant_details.id;Output:
name
total_number_of_orders
first_time_orders
Treehouse Pizza
8
1
Thai Lion
14
7
Meal Raven
12
0
Burger A1
4
0
Sushi Bay
7
3
Tacos You
7
1
# Percentile and Ranking Analysis
**
Aggregate functions tell you the average. Window-based ranking functions tell you the distribution, and distributions are where the interesting business questions live. Is your 90th percentile order value unusually high, suggesting a few large buyers are skewing revenue? Are the bottom 25% of sales reps clustered close to the median or far below?
NTILE(n) divides rows into n roughly equal buckets. PERCENT_RANK() expresses each row's rank as a value between 0 and 1. CUME_DIST() tells you what fraction of rows have a value less than or equal to the current row. And PERCENTILE_CONT() computes the actual value at a given percentile threshold — useful when you want to filter based on a dynamic cutoff rather than rank within a result set.

// Example: Identifying Top Percentile Fraud
Here's one by Google and Netflix. They want you to identify the most suspicious claims in each state. The assumption is that the top 5% of claims in each state are potentially fraudulent.
Data:** The table is named fraud_score.
policy_num
state
claim_cost
fraud_score
ABCD1001
CA
4113
0.61
ABCD1002
CA
3946
0.16
ABCD1003
CA
4335
0.01
...
...
...
...
ABCD1400
TX
3922
0.59
Code: In the code, PERCENTILE_CONT(0.95) computes the interpolated value at the 95th percentile of fraud scores within each state.
In the following SELECT statement, the CTE is joined with the original table so every claim can be compared against the threshold for its own state. Claims at or above that value make the cut.
WITH state_percentiles AS (
SELECT state,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY fraud_score) AS p95
FROM fraud_score
GROUP BY state)
SELECT f.policy_num,
f.state,
f.claim_cost,
f.fraud_score
FROM fraud_score f
JOIN state_percentiles sp
ON f.state = sp.state
WHERE f.fraud_score >= sp.p95;Output:
policy_num
state
claim_cost
fraud_score
ABCD1016
CA
1639
0.96
ABCD1021
CA
4898
0.95
ABCD1027
CA
2663
0.99
...
...
...
...
ABCD1398
TX
3191
0.98
# Conclusion
**
These four patterns share a common philosophy: do the work in the database, in a single pass where possible, using the full expressive power of the SQL window specification.
What makes window functions genuinely powerful isn't any single function in isolation. It's the composability: you can chain CTEs, apply multiple window functions in the same SELECT, and build complex analytical logic that reads nearly like a description of the business problem itself.
Nate Rosidi** is a data scientist and in product strategy. He's also an adjunct professor teaching analytics, and is the founder of StrataScratch, a platform helping data scientists prepare for their interviews with real interview questions from top companies. Nate writes on the latest trends in the career market, gives interview advice, shares data science projects, and covers everything SQL.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み