データアナリストのためのSQL:ウィンドウ関数、CTE、高度なクエリ技法

SQLのウィンドウ関数、CTE(共通テーブル式)、高度な分析クエリをコード例付きで解説。データアナリスト面接対策と実務に直結する必須テクニック。

データアナリストのためのSQL:ウィンドウ関数、CTE、高度なクエリ

SQLのウィンドウ関数、CTE(共通テーブル式)、高度なクエリパターンは、分析SQLの基盤を構成する要素です。データアナリスト面接の準備であっても、複雑なレポーティングクエリへの対応であっても、これらのテクニックを習得することで、冗長で可読性の低いサブクエリを、簡潔でパフォーマンスの高いSQLに変換できます。

クイックリファレンス

ウィンドウ関数は、現在の行に関連する行の集合に対して計算を実行します。GROUP BYのように結果を1行に集約するのではなく、各行をそのまま保持したまま計算結果を付加できる点が特徴です。CTEと組み合わせることで、複雑な分析クエリも読みやすく保守しやすい形にまとめることができます。

SQLウィンドウ関数とOVER句の基本

ウィンドウ関数は、定義された「ウィンドウ」(行の範囲)に対して計算を適用します。OVER句がそのウィンドウに含まれる行と、行の並び順を制御します。GROUP BYを使った集約関数とは異なり、ウィンドウ関数は結果セットの各行をすべて保持します。

以下の構文は、PostgreSQL、MySQL 8以降、BigQuery、SQL Server、Snowflakeなど、主要なデータベースで共通して使用できます。

sql
-- window_function_syntax.sql
SELECT
  employee_id,
  department,
  salary,
  -- Ranks employees within each department by salary
  RANK() OVER (
    PARTITION BY department
    ORDER BY salary DESC
  ) AS dept_salary_rank,
  -- Running total of salaries within department
  SUM(salary) OVER (
    PARTITION BY department
    ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_salary_total
FROM employees;

PARTITION BYはデータをグループに分割します(GROUP BYに類似しますが、行を集約しません)。ORDER BYは各パーティション内の順序を定義します。オプションのフレーム句(ROWS BETWEEN ...)は、ウィンドウを特定の行の範囲に限定します。

ROW_NUMBER、RANK、DENSE_RANKの比較

これら3つのランキング関数は見た目が似ていますが、同順位(タイ)が存在する場合の挙動が異なります。誤った関数を選択することは、分析クエリにおけるバグの頻出原因のひとつです。

sql
-- ranking_comparison.sql
SELECT
  product_name,
  category,
  revenue,
  ROW_NUMBER() OVER (ORDER BY revenue DESC) AS row_num,
  RANK()       OVER (ORDER BY revenue DESC) AS rank_val,
  DENSE_RANK() OVER (ORDER BY revenue DESC) AS dense_rank_val
FROM products;

| product_name | revenue | row_num | rank_val | dense_rank_val | |-------------|---------|---------|----------|----------------| | Widget A | 50000 | 1 | 1 | 1 | | Widget B | 50000 | 2 | 1 | 1 | | Widget C | 40000 | 3 | 3 | 2 | | Widget D | 30000 | 4 | 4 | 3 |

ROW_NUMBERは常に一意の連番を割り当てます。同じ値の行が存在する場合でも、いずれかの行に任意に小さい番号が付与されます。RANKは同順位の行に同じ番号を割り当てますが、後続の番号を飛ばします(1, 1, 3)。DENSE_RANKも同順位を処理しますが、番号を飛ばしません(1, 1, 2)。上位N件のクエリで重複する値が同じ順位を共有すべき場合は、DENSE_RANKが適切な選択です。

LAGとLEADによる期間比較分析

LAGとLEADは、セルフジョインを使わずに、前後の行のデータにアクセスできます。前期比の成長率計算、トレンド検出、変化の特定に不可欠な関数です。

sql
-- period_over_period.sql
SELECT
  month,
  revenue,
  -- Previous month revenue
  LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
  -- Month-over-month growth rate
  ROUND(
    (revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0
    / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
    2
  ) AS mom_growth_pct,
  -- Next month revenue (forward-looking)
  LEAD(revenue, 1) OVER (ORDER BY month) AS next_month_revenue
FROM monthly_sales
ORDER BY month;

LAG/LEADの第2引数はオフセット(デフォルトは1)を指定します。第3引数(オプション)は、該当オフセットに行が存在しない場合のデフォルト値を指定でき、先頭行や末尾行でのNULL回避に有効です。成長率計算のNULLIFは、ゼロ除算エラーを防止します。

NTILEによるパーセンタイル分割

NTILEは、指定した数のほぼ等しいグループに行を分配します。データアナリストは四分位分析、十分位スコアリング、顧客セグメンテーションにこの関数を活用します。

sql
-- customer_segmentation.sql
SELECT
  customer_id,
  total_spend,
  -- Split customers into 4 quartiles by spend
  NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_quartile,
  -- Decile scoring for finer granularity
  NTILE(10) OVER (ORDER BY total_spend DESC) AS spend_decile
FROM (
  SELECT
    customer_id,
    SUM(order_total) AS total_spend
  FROM orders
  WHERE order_date >= '2025-01-01'
  GROUP BY customer_id
) customer_totals;

四分位1には最も支出額の高い顧客が、四分位4には最も低い顧客が含まれます。このパターンは、マーケティング分析で使用されるRFM(Recency、Frequency、Monetary)セグメンテーションに直接対応しています。

Data Analyticsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

共通テーブル式(CTE):ネストされたサブクエリの置き換え

CTE(WITH句)は、複雑なクエリを名前付きの読みやすいステップに分解します。各CTEは、そのクエリの実行期間中のみ存在する一時的な名前付き結果セットとして機能します。可読性の向上に加えて、各ステップを独立してテストできるため、デバッグも容易になります。

sql
-- cte_sales_analysis.sql
WITH monthly_revenue AS (
  -- Step 1: Aggregate raw orders into monthly totals
  SELECT
    DATE_TRUNC('month', order_date) AS month,
    product_category,
    SUM(amount) AS revenue,
    COUNT(DISTINCT customer_id) AS unique_customers
  FROM orders
  WHERE order_date >= '2025-01-01'
  GROUP BY DATE_TRUNC('month', order_date), product_category
),
ranked_categories AS (
  -- Step 2: Rank categories within each month
  SELECT
    month,
    product_category,
    revenue,
    unique_customers,
    RANK() OVER (
      PARTITION BY month
      ORDER BY revenue DESC
    ) AS category_rank
  FROM monthly_revenue
)
-- Step 3: Final output — top 3 categories per month
SELECT
  month,
  product_category,
  revenue,
  unique_customers,
  category_rank
FROM ranked_categories
WHERE category_rank <= 3
ORDER BY month, category_rank;

この3ステップのアプローチは、深くネストされたサブクエリの代替として機能します。各CTEには明確な責任があり、集約、ランキング、フィルタリングの役割が分離されています。

再帰CTEによる階層データの処理

再帰CTEは、階層構造やグラフ型のデータに関する問題を解決します。組織図、カテゴリツリー、部品表(BOM)、経路探索クエリなどが典型的なユースケースです。

sql
-- recursive_org_chart.sql
WITH RECURSIVE org_hierarchy AS (
  -- Base case: top-level managers (no manager above them)
  SELECT
    employee_id,
    employee_name,
    manager_id,
    1 AS depth,
    employee_name AS management_chain
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive case: join employees to their managers
  SELECT
    e.employee_id,
    e.employee_name,
    e.manager_id,
    oh.depth + 1,
    oh.management_chain || ' > ' || e.employee_name
  FROM employees e
  INNER JOIN org_hierarchy oh ON e.manager_id = oh.employee_id
)
SELECT
  employee_id,
  employee_name,
  depth,
  management_chain
FROM org_hierarchy
ORDER BY management_chain;

ベースケースはルートノード(上司のいない従業員)を選択します。再帰ケースはCTE自身に結合し、階層をレベルごとに構築します。management_chain列は名前を連結し、完全な報告パスを表示します。ほとんどのデータベースでは無限ループを防止するために再帰の深さが制限されており、PostgreSQLのデフォルトは100回です。

再帰CTEのパフォーマンス

再帰CTEは大規模なデータセットでは処理が遅くなる可能性があります。深さの制限(WHERE depth < 10)を必ず含め、結合列(manager_id)にインデックスが設定されていることを確認してください。非常に深い階層構造の場合は、マテリアライズドパスやネステッドセットパターンの採用を検討してください。

高度な分析パターン:ギャップとアイランド、累計

ギャップとアイランドパターンは、データ内の連続するシーケンスを識別します。アクティブなサブスクリプション期間、連続ログイン日数、途切れのない稼働期間などが該当します。このテクニックはROW_NUMBERと日付演算を組み合わせます。

sql
-- consecutive_login_streaks.sql
WITH login_days AS (
  SELECT DISTINCT
    user_id,
    DATE(login_timestamp) AS login_date
  FROM user_sessions
),
streaks AS (
  SELECT
    user_id,
    login_date,
    -- Subtracting row_number from date creates a constant for consecutive days
    login_date - (ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY login_date
    ))::int AS streak_group
  FROM login_days
)
SELECT
  user_id,
  MIN(login_date) AS streak_start,
  MAX(login_date) AS streak_end,
  COUNT(*) AS streak_length
FROM streaks
GROUP BY user_id, streak_group
HAVING COUNT(*) >= 3
ORDER BY streak_length DESC;

このテクニックの核心は、連続する日付から増加する行番号を引くと、常に同じ値が得られるという点にあります。ギャップが発生すると結果の値が変化し、新しいグループが形成されます。このクエリは、ユーザーごとに3日以上の連続ログインストリークをすべて検出します。

ウィンドウ関数とCASE式の組み合わせによる条件付き分析

実務の分析クエリでは、ウィンドウ関数とCASE式を組み合わせて、同一クエリ内で条件付きメトリクスを計算することが頻繁にあります。

sql
-- conditional_analytics.sql
SELECT
  order_date,
  product_category,
  amount,
  -- 7-day moving average
  AVG(amount) OVER (
    PARTITION BY product_category
    ORDER BY order_date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS moving_avg_7d,
  -- Cumulative sum resetting each quarter
  SUM(amount) OVER (
    PARTITION BY product_category, DATE_TRUNC('quarter', order_date)
    ORDER BY order_date
  ) AS qtd_cumulative,
  -- Flag if current row exceeds 2x the category average
  CASE
    WHEN amount > 2 * AVG(amount) OVER (PARTITION BY product_category)
    THEN 'OUTLIER'
    ELSE 'NORMAL'
  END AS outlier_flag
FROM daily_sales
ORDER BY product_category, order_date;

この単一のクエリで、7日間移動平均、四半期ごとにリセットされる累計、外れ値フラグの3つを同時に計算できます。サブクエリやセルフジョインは一切不要です。

ウィンドウ関数の実行順序

ウィンドウ関数はWHERE、GROUP BY、HAVINGの後に実行され、ORDER BYとLIMITの前に実行されます。そのため、WHERE句内でウィンドウ関数を直接使用することはできません。ウィンドウ関数の結果でフィルタリングするには、クエリをCTEまたはサブクエリでラップしてください。

分析クエリのパフォーマンス最適化

ウィンドウ関数とCTEは強力なツールですが、大規模なテーブルではボトルネックになる可能性があります。スケーラブルなパフォーマンスを維持するためのテクニックを紹介します。

第一に、PARTITION BYおよびORDER BY句で使用される列にインデックスを作成します。ウィンドウ定義に一致する複合インデックスがあれば、ソート操作を省略できます。

第二に、ウィンドウ関数を適用する前にデータをフィルタリングします。WHERE句(ウィンドウ関数の評価前に実行される)に条件を配置することで、ウィンドウが処理するデータセットを削減できます。

第三に、冗長なウィンドウ定義を避けます。名前付きウィンドウを使うことで繰り返しを減らし、オプティマイザに対して複数の関数が同じフレームを共有していることを示すことができます。

sql
-- named_window.sql
SELECT
  department,
  employee_id,
  salary,
  RANK() OVER dept_window AS dept_rank,
  SUM(salary) OVER dept_window AS dept_total,
  AVG(salary) OVER dept_window AS dept_avg
FROM employees
WINDOW dept_window AS (
  PARTITION BY department
  ORDER BY salary DESC
);

WINDOW句(PostgreSQL、MySQL 8以降、BigQueryでサポート)は、ウィンドウを一度定義して複数の関数で再利用できます。可読性の向上とクエリプラン最適化の両方に寄与します。

Data Analyticsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

まとめ

  • ウィンドウ関数(ROW_NUMBER、RANK、DENSE_RANK、LAG、LEAD、NTILE)は、GROUP BY集約とは異なり、行を集約せずに行の集合に対して計算を実行します
  • CTEは複雑なクエリを名前付きのテスト可能なステップに分解し、深くネストされたサブクエリを置き換えます
  • 再帰CTEは組織図やカテゴリツリーなどの階層データを処理しますが、深さの制限とインデックス付き結合列が必要です
  • ギャップとアイランドテクニック(ROW_NUMBER+日付演算)は、時系列データの連続シーケンスを識別します
  • 名前付きウィンドウ(WINDOW句)はコードの重複を減らし、クエリプランの最適化を改善する可能性があります
  • WHERE句でウィンドウ関数の評価前にデータをフィルタリングすることで、大規模テーブルでのパフォーマンスを維持できます
  • これらのパターンはデータアナリスト面接で頻出であり、日常のレポーティング、セグメンテーション、トレンド分析にも直接応用できます

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#sql
#data-analytics
#window-functions
#cte
#interview

共有

関連記事