0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【備忘録】Snowflake SQL 失敗から学んだこと

0
Posted at

Snowflake SQLで気をつけていること:失敗から学んだ実践まとめ

データエンジニアリングの業務でSnowflake SQLを書き続ける中で、「あのとき気をつけていれば…」という失敗をいくつも経験しました。この記事は自分への反省も込めて、やらかしたパターンと、そこから得たベストプラクティスをまとめたものです。

1. インデントと書式:カンマは行末に置いていた

やらかしたこと

最初はカンマを行末に置いていました。

-- ❌ カンマ行末スタイル(昔の自分)
SELECT
    a.user_id,
    b.order_id,
    c.product_name
FROM ...

何が困るかというと、カラムをコメントアウトするときです。

-- カラムを一時的に消したいとき、行末カンマだと前の行も触らないといけない
SELECT
    a.user_id,
    -- b.order_id,   ← コメントアウトしたら上の行末カンマが残って構文エラー
    c.product_name
FROM ...

また、Gitの差分でも「カンマが追加された行」と「カラムが追加された行」が混ざって見づらくなります。

今の書き方

-- ✅ カンマ行頭スタイル
SELECT
    a.user_id
    , b.order_id
    , c.product_name

FROM users AS a

LEFT JOIN orders AS b
    ON a.user_id = b.user_id

LEFT JOIN products AS c
    ON b.product_id = c.product_id

WHERE
    a.status = 'active'

カンマを行頭に置くと、カラムを1行丸ごとコメントアウト/削除できて、差分も縦に揃います。地味ですが、毎日書くSQLだと積み重なって効いてきます。

他にも意識していること:

  • FROM / JOIN / WHERE は大文字・独立した行で
  • ON 句はJOINより1段インデントを深く
  • カラムは1行ずつ縦に並べる

2. リーダブルコード:意図を伝えるSQL

CTEでロジックを分割する

長いSQLをサブクエリで書くと、ネストが深くなって読めなくなります。WITH句(CTE)で処理を段階的に命名することで、「このCTEは何をしているのか」が一目でわかるようになります。

-- ❌ ネストが深くて読みづらい
SELECT *
FROM (
    SELECT user_id, SUM(amount) AS total
    FROM (
        SELECT * FROM orders WHERE status = 'completed'
    )
    GROUP BY user_id
) AS sub
WHERE total > 10000;


-- ✅ CTEで意図が明確
WITH completed_orders AS (
    -- 完了済み注文のみ抽出
    SELECT
        user_id
        , amount
    FROM orders
    WHERE status = 'completed'
)

, user_totals AS (
    -- ユーザーごとの合計金額を集計
    SELECT
        user_id
        , SUM(amount) AS total_amount
    FROM completed_orders
    GROUP BY user_id
)

SELECT *
FROM user_totals
WHERE total_amount > 10000

コメントで「なぜ」を書く

-- 対象期間:前月のデータのみ(当月は締め処理が未完了のため除外)
WHERE
    order_date >= DATE_TRUNC('month', DATEADD('month', -1, CURRENT_DATE))
    AND order_date < DATE_TRUNC('month', CURRENT_DATE)

「何をしているか」はコードを読めばわかります。コメントには「なぜこう書いているか」を残すのが大事です。

3. JOINの書き方:a, b, c でやっていた

やらかしたこと

最初はテーブルの別名を a, b, c のように連番でつけていました。

-- ❌ 昔の自分のコード
SELECT a.name, b.amount, c.category
FROM users a
LEFT JOIN orders b ON a.id = b.user_id
LEFT JOIN products c ON b.product_id = c.id

書いている本人はわかっていても、1週間後に見返すと「b って何だっけ?」となります。レビューする人も a が何のテーブルかを都度確認しなければいけない。

今の書き方

-- ✅ 意味のある別名にする
SELECT
    u.name
    , o.amount
    , p.category

FROM users AS u

LEFT JOIN orders AS o
    ON u.id = o.user_id

LEFT JOIN products AS p
    ON o.product_id = p.id

u = users、o = orders、p = products。テーブル名の頭文字や略称をつけるだけで、読みやすさが大きく変わります。

LEFT JOINのとき、SELECTで使うテーブルを意識する

もう一つ気をつけるようになったのが、A LEFT JOIN B のとき、SELECT句でどちらのカラムを使うかです。

LEFT JOINは「Aを全件保持しながらBを付け足す」構造なので、基本的にSELECTで参照するのはAのカラムが主役になるはずです。

-- ⚠️ LEFT JOINなのにBのカラムを不用意に使っている
SELECT
    u.user_id
    , o.user_id   -- ← Bのuser_idを使う意味は? NULLになる場合がある

FROM users AS u

LEFT JOIN orders AS o
    ON u.user_id = o.user_id


-- ✅ 結合の「主役」であるAのカラムを使う
SELECT
    u.user_id     -- ← こちらがNULLになることはない
    , o.order_id  -- BはNULLになり得ることを意識した上で使う
    , o.amount

FROM users AS u

LEFT JOIN orders AS o
    ON u.user_id = o.user_id

結合キーのような「同名カラム」は特に注意が必要で、NULLが混入するかどうかは「どちらのテーブルから取ったか」で変わります。

4. JOINの種類と使い分け:単体テストで助かった概念

INNER JOIN / LEFT JOIN / UNION ALLの違い

最初は「なんとなく LEFT JOIN を使えばいいや」という感じでしたが、JOINの種類をきちんと理解したことで、単体テスト時に本当に役立ちました

実装SQLとは別の角度から「このデータが出てくるはず」を検証するとき、JOINの挙動を理解していないと正しいテストが書けないからです。

INNER JOIN:両方に存在するデータのみ

-- 注文があるユーザーだけ取得したい場合
SELECT
    u.user_id
    , u.name
    , o.order_id

FROM users AS u

INNER JOIN orders AS o
    ON u.user_id = o.user_id
-- 注文がないユーザーは結果に出てこない(件数が意図せず減る)

LEFT JOIN:左テーブルは全件保持

-- 注文していないユーザーも含めて取得したい場合
SELECT
    u.user_id
    , u.name
    , o.order_id  -- 注文がないユーザーはNULL

FROM users AS u

LEFT JOIN orders AS o
    ON u.user_id = o.user_id

WHERE o.order_id IS NOT NULL を後から付けると実質INNER JOINになるので、意図的かどうかを意識することが大事です。

UNION vs UNION ALL

-- UNION:重複を除去(ソート処理が走るので遅い)
SELECT user_id FROM table_a
UNION
SELECT user_id FROM table_b

-- UNION ALL:重複を除去しない(速い)
-- 重複が発生しないとわかっている場合はこちら
SELECT user_id FROM table_a
UNION ALL
SELECT user_id FROM table_b
用途 推奨
重複除去が必要 UNION
重複しないとわかっている(年月別テーブルの縦積みなど) UNION ALL(速い)

テストで役立った理由

例えば「ユーザーマスタに存在するのに集計結果に出てこない」というバグを調査するとき、INNER JOINとLEFT JOINの違いを理解していれば「JOINの途中で落ちているのでは?」という仮説をすぐ立てられます。実装SQLとは別角度でLEFT JOINを使って「本来あるべきレコード」を洗い出す、というテクニックも使えるようになりました。

5. CAST:最後にやって後悔した

やらかしたこと

型変換を「最後にまとめてやればいいや」と思っていたのが間違いでした。

-- ❌ 処理の末尾でCASTする(昔の自分)
WITH aggregated AS (
    SELECT
        user_id
        , SUM(amount) AS total_amount  -- amountがVARCHARのまま集計 → 意図しない結果
    FROM raw_events
    GROUP BY user_id
)

SELECT
    CAST(user_id AS INT) AS user_id
    , CAST(total_amount AS DECIMAL(18,2)) AS total_amount
FROM aggregated

amount が文字列型のまま SUM() に渡されると、エラーになるか、暗黙の型変換で予期しない結果になります。また、JOINキーの型が合っていないとフルスキャンが走って激遅になることもありました。

今の書き方:入口でCASTする

-- ✅ 最初のCTEでCASTしてしまう
WITH casted_events AS (
    SELECT
        CAST(user_id AS INT)             AS user_id
        , CAST(amount AS DECIMAL(18, 2)) AS amount
        , CAST(event_date AS DATE)       AS event_date
    FROM raw_events
)

, aggregated AS (
    SELECT
        user_id
        , SUM(amount) AS total_amount  -- 型が保証された状態で集計
    FROM casted_events
    GROUP BY user_id
)

SELECT * FROM aggregated

TRY_CAST を使うと、型変換に失敗してもエラーにならずNULLを返してくれるので、生データが汚い場合に重宝します。

-- 型変換に失敗してもエラーにならない(NULLで返る)
SELECT TRY_CAST(dirty_value AS INT) AS safe_int
FROM raw_table

6. 処理を速くする:最初はパフォーマンスを意識していなかった

やらかしたこと

最初はとにかく「正しい結果が出ればOK」という考えで、パフォーマンスはほぼ無視していました。特にやりがちだったのが、期間フィルタを最後に書くパターンです。

-- ❌ 全データを処理してから最後に絞る(遅い)
WITH all_events AS (
    SELECT * FROM events  -- 数千万行を全件スキャン
)

, aggregated AS (
    SELECT user_id, COUNT(*) AS cnt
    FROM all_events
    GROUP BY user_id  -- 全件集計してから...
)

SELECT * FROM aggregated
WHERE event_date >= DATE(:target_ym || '01')  -- 最後にフィルタ → 手遅れ

aggregated を作った段階ですでに event_date が集計で消えているので、このWHEREはそもそも効きません。その前に全件スキャン・全件集計が走っています。

今の書き方:最初のCTEで絞る

-- ✅ 最初のCTEで期間を絞ってからすべての処理をする
WITH target_events AS (
    SELECT
        user_id
        , event_date
        , amount
    FROM events
    WHERE
        -- ここで絞る。DATE(:target_ym || '01') でも DATE(:target_ym) でも結果は同じ
        event_date >= DATE(:target_ym || '01')
        AND event_date < DATEADD('month', 1, DATE(:target_ym || '01'))
)

, aggregated AS (
    SELECT
        user_id
        , COUNT(*) AS cnt
        , SUM(amount) AS total_amount
    FROM target_events  -- 絞った後のデータだけ集計
    GROUP BY user_id
)

SELECT * FROM aggregated

JOINの前にも同じ考え方を適用します。

-- ✅ 各テーブルを絞ってからJOINする
WITH jp_users AS (
    SELECT user_id, name
    FROM users
    WHERE region = 'JP'  -- ① 先に絞る
)

, jan_orders AS (
    SELECT user_id, amount, order_date
    FROM orders
    WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31'  -- ② 先に絞る
)

SELECT
    u.user_id
    , o.amount

FROM jp_users AS u

LEFT JOIN jan_orders AS o
    ON u.user_id = o.user_id
-- JOINする時点でどちらも行数が絞られている

その他の速度改善テクニック

-- ① SELECT * は使わない(必要なカラムだけ取得)
-- ❌
SELECT * FROM large_table

-- ✅
SELECT user_id, amount, event_date FROM large_table


-- ② QUALIFY を使ってサブクエリを減らす(Snowflake特有)
-- ❌ ROW_NUMBERをサブクエリでフィルタ
SELECT * FROM (
    SELECT
        *
        , ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM orders
) WHERE rn = 1

-- ✅ QUALIFYで直接フィルタ(すっきり書ける)
SELECT *
FROM orders
QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1

まとめ

カテゴリ 反省点 今の方針
インデント カンマを行末に置いていた カンマ行頭、差分・コメントアウトしやすく
可読性 サブクエリをネストしまくっていた CTEで分割、コメントで「なぜ」を残す
JOIN別名 a, b, c で書いていた 意味のある略称、LEFT JOINの主役はA
JOIN種別 なんとなく使っていた 挙動を理解してテストにも活かす
CAST 処理の最後に型変換していた 最初のCTEで入口をそろえる
パフォーマンス 期間フィルタを最後に書いていた 最初のCTEで行数を絞る

「動けばいい」から「読める・速い・壊れにくい」へ。どれも一度ハマった経験から得た教訓です。同じ失敗をする人が減れば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?