SQL バッチ処理 — DISTINCT・NULL処理・LIKEの基礎

基礎SQL基礎文法DISTINCT / NULL / LIKEIN・EXISTS / 日付関数WINDOW / UNIONトランザクション・UPSERTPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

DISTINCT + COUNT(DISTINCT) — 重複排除とユニークカウントで実態を把握する

DISTINCTCOUNT(DISTINCT)重複排除分析API
前提知識

DISTINCT は SELECT の結果から重複する行を取り除きます。COUNT(DISTINCT 列名) はNULLを除いたユニークな値の数を数えます。「何件のデータがあるか」ではなく「何種類の値があるか」を知りたいときの基本です。

SELECT DISTINCT col1, col2       -- col1+col2の組み合わせが重複する行を除去
FROM   table_name;

SELECT COUNT(DISTINCT col1)      -- col1 のユニーク値の数を数える(NULLは除外)
FROM   table_name;

「サイトに何人のユニークユーザーがアクセスしたか」「何種類の商品が購入されたか」など、重複を除いた実態把握はAPI・バッチのあらゆる集計で頻出です。

COUNT(*) との違い:COUNT(*) は全行数、COUNT(DISTINCT col) はユニークな値の数。COUNT(col)(DISTINCTなし)はNULLを除いた行数。3つの違いを正確に理解すること。
問題

access_logs テーブルから、2024年5月の①ユニークユーザー数(user_id の重複を除いた件数)と②アクセス総件数を1行で取得してください。また別のクエリで、アクセスしたユーザーID一覧(重複なし)を user_id の昇順で取得してください。

使用テーブル
▸ access_logs
log_iduser_idpageaccessed_at
1U01/home2024-05-01
2U02/items2024-05-03
3U01/items2024-05-05
4U03/home2024-05-10
5U01/cart2024-05-12
6U02/home2024-05-20
7U04/items2024-06-01
期待出力

期待する出力①:

unique_userstotal_access
36

期待する出力②:

user_id
U01
U02
U03
模範解答コード
-- ① ユニークユーザー数とアクセス総件数を集計
SELECT
  COUNT(DISTINCT user_id) AS unique_users,  -- 重複を除いた user_id の種類数
  COUNT(*)               AS total_access   -- 全行数(総アクセス数)
FROM  access_logs
WHERE accessed_at >= '2024-05-01'           -- 5月以降
  AND accessed_at <  '2024-06-01';          -- 5月末まで

-- ② アクセスしたユーザーID一覧(重複排除・昇順)
SELECT DISTINCT user_id             -- 重複する user_id を除去
FROM   access_logs
WHERE  accessed_at >= '2024-05-01'  -- 5月以降
  AND  accessed_at <  '2024-06-01'  -- 5月末まで
ORDER BY user_id ASC;

/*
  実行順序(① 集計クエリ):
  1. FROM access_logs        → 行を読み込む
  2. WHERE accessed_at ...   → 行を絞り込む
  3. SELECT                  → 集計関数を評価(unique_users, total_access)

  実行順序(② DISTINCT クエリ):
  1. FROM access_logs        → 行を読み込む
  2. WHERE accessed_at ...   → 行を絞り込む
  3. SELECT DISTINCT user_id → 重複を除去
  4. ORDER BY user_id ASC    → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT COUNT(DISTINCT user_id) AS unique_users, COUNT(*) AS total_access FROM access_logs WHERE accessed_at >= '2024-05-01' AND accessed_at < '2024-06-01';
LEGEND
データ取得・読込対象
① FROM
FROM access_logsaccess_logs テーブル全体(7行)を読み込みます。次の WHERE ステップで5月の行のみに絞り込みます。
1 / 3
log_iduser_idpageaccessed_at
1U01/home2024-05-01
2U02/items2024-05-03
3U01/items2024-05-05
4U03/home2024-05-10
5U01/cart2024-05-12
6U02/home2024-05-20
7U04/items2024-06-01
全 7行 読込
SELECT DISTINCT user_id FROM access_logs WHERE accessed_at >= '2024-05-01' AND accessed_at < '2024-06-01' ORDER BY user_id ASC;
LEGEND
データ取得・読込対象
① FROM + WHERE
FROM access_logs WHERE 5月access_logs から WHERE で5月の行に絞り込みます。U04(6月)は除外され6行が残ります。
1 / 3
log_iduser_idaccessed_at
1U012024-05-01
2U022024-05-03
3U012024-05-05
4U032024-05-10
5U012024-05-12
6U022024-05-20
WHERE絞込後: 6行
学習ポイント
超基礎:DISTINCT は「同じ値の行を1行にまとめる」フィルターです。複数列に指定した場合は列の組み合わせが重複している行を除去します(例: SELECT DISTINCT user_id, page はユーザー+ページの組み合わせ単位で重複排除)。
COUNT の3つの使い分け:COUNT(*)=NULLを含む全行数、COUNT(col)=NULLを除いた行数、COUNT(DISTINCT col)=ユニークな値の数。アクセス解析やKPIダッシュボードAPIでは3つすべて使うことがある。
GROUP BY との使い分け:「ユーザーIDの一覧が欲しいだけ」なら SELECT DISTINCT user_id が簡潔。「ユーザーごとのアクセス件数も欲しい」なら SELECT user_id, COUNT(*) ... GROUP BY user_id を使う。
パフォーマンス注意:DISTINCT や COUNT(DISTINCT) は内部でソート処理が発生するため、大量データでは重くなりがち。対象列にインデックスがあると高速化できる。分析用途では近似カウント関数(HyperLogLog等)を使うケースもある。
アンチパターン
SELECT DISTINCT * の乱用:全列DISTINCTはどの列の組み合わせで重複を除くのか意図が不明確になり、意図しない結果を生みやすい。必要な列を明示して SELECT DISTINCT user_id のように絞ること。
COUNT(*) でユニーク数を数えようとする:COUNT(*) は重複を含む全行数なので「ユニークユーザー数=COUNT(*)」は誤り。U01が3回アクセスしていたら3がカウントされる。ユニーク数には必ず COUNT(DISTINCT user_id) を使うこと。
実務コラム:ユニークユーザー計測の難しさ
Webサービスの「ユニークユーザー(UU)数」は一般的にはcookieやデバイスIDで識別されますが、ログインユーザーだけを対象とするなら COUNT(DISTINCT user_id) で正確に算出できます。一方でゲストユーザーを含む場合や、同一ユーザーが複数デバイスでアクセスする場合は過大・過少カウントが発生します。KPI定義とSQL実装は必ずセットで仕様を確認する習慣が重要です。
QUESTION 2

COALESCE / IS NULL / NULLIF — NULLを安全に扱いAPIレスポンスを壊さない

COALESCEIS NULLNULLIFNULL安全
前提知識

SQLのNULLは「値が存在しない・不明」を表す特殊な状態です。NULLへの算術演算・文字列結合の結果はすべてNULLになります。APIレスポンスにNULLが混入すると予期しない動作を引き起こすため、明示的なNULL処理が必須です。

COALESCE(col, 'デフォルト値')   -- 左から評価し、最初にNULLでない値を返す
col IS NULL                       -- NULL の判定(= NULL は不可、必ず IS NULL)
col IS NOT NULL                   -- NULLでないことの判定
NULLIF(col, 0)                   -- col が 0 なら NULL を返す(ゼロ除算防止等に使用)
NULL = NULL は FALSE:WHERE col = NULL は常にFALSEになり1行もヒットしない。NULLの比較には必ず IS NULL または IS NOT NULL を使うこと。これはSQLの最重要注意事項の1つ。
問題

users テーブルから全ユーザーを取得してください。phone が NULL のユーザーには '未登録' を表示し、display_name が NULL の場合は name を代わりに使用してください。また phone が NULL であるユーザーの件数も別のクエリで取得してください。

使用テーブル
▸ users
user_idnamedisplay_namephone
1田中 太郎タナカ090-1111-2222
2佐藤 花子NULL080-3333-4444
3鈴木 一郎スズキNULL
4山田 次郎NULLNULL
期待出力

期待する出力①:

user_idshown_namephone_display
1タナカ090-1111-2222
2佐藤 花子080-3333-4444
3スズキ未登録
4山田 次郎未登録

期待する出力②:

null_phone_count
2
模範解答コード
-- ① NULLを安全なデフォルト値に置き換えて全ユーザーを取得
SELECT
  user_id,
  COALESCE(display_name, name) AS shown_name,  -- NULL なら name を使う
  COALESCE(phone, '未登録')    AS phone_display  -- NULL なら '未登録'
FROM  users
ORDER BY user_id;

-- ② phone が NULL のユーザー数を集計
SELECT
  COUNT(*) AS null_phone_count  -- IS NULL で絞った後の件数
FROM  users
WHERE phone IS NULL;            -- NULL は IS NULL で判定(= NULL は不可)

/*
  実行順序(① NULLデフォルト変換クエリ):
  1. FROM users          → 行を読み込む
  2. SELECT              → 列を評価(COALESCE で NULL を補正)
  3. ORDER BY user_id    → 並び替えて出力

  実行順序(② NULL件数カウントクエリ):
  1. FROM users          → 行を読み込む
  2. WHERE phone IS NULL → 行を絞り込む
  3. SELECT null_phone_count → 集計関数を評価
*/
解説(テーブル変化・ポイント)
SELECT user_id, COALESCE(display_name, name) AS shown_name, COALESCE(phone, '未登録') AS phone_display FROM users ORDER BY user_id;
LEGEND
データ取得・読込対象
① FROM
FROM usersusers テーブル全体(4行)を読み込みます。display_name と phone にそれぞれ NULL が含まれています。
1 / 3
user_idnamedisplay_namephone
1田中 太郎タナカ090-1111-2222
2佐藤 花子NULL080-3333-4444
3鈴木 一郎スズキNULL
4山田 次郎NULLNULL
全 4行 読込
SELECT COUNT(*) AS null_phone_count FROM users WHERE phone IS NULL;
LEGEND
データ取得・読込対象
① FROM
FROM usersusers テーブル全体(4行)を読み込みます。phone が NULL の行(user_id=3,4)を WHERE で特定します。
1 / 3
user_idnamephone
1田中 太郎090-1111-2222
2佐藤 花子080-3333-4444
3鈴木 一郎NULL
4山田 次郎NULL
全 4行 読込
学習ポイント
超基礎:NULLの特殊性:NULLは「値がない」状態なので、NULL同士を == で比較することができません。NULL = NULL はTRUEではなくNULL(= 不明)になります。NULLを比較するには必ず IS NULL / IS NOT NULL を使います。
COALESCE の引数は3つ以上も可能:COALESCE(nickname, display_name, name, '名無し') のように複数フォールバックを書ける。左から評価して最初のNULLでない値を返す。全部NULLなら最後の固定値が使われる。
NULLIF の用途(ゼロ除算防止):SUM(amount) / NULLIF(COUNT(*), 0) と書くと、COUNT(*)が0のとき NULLIF が NULLを返し、ゼロ除算エラーを防げる。DBが空の場合でも安全に平均を計算できる。
NULL と集計関数:SUMAVGCOUNT(col) はNULLを無視して集計する。例えばphone列に5行あってNULLが2行あれば COUNT(phone) は3になる。これを利用して COUNT(phone) でNULL以外の件数を数えることもできる。
アンチパターン
WHERE col = NULL で絞り込もうとする:最頻出のNULLミス。= NULL は論理的に「NULLとNULLが等しいか不明」なので常にFALSEになり、0件ヒットになる。必ず IS NULL を使うこと。
NULL を含む計算を見落とす:price * quantity のどちらかがNULLなら結果もNULLになる。APIで合計金額をNULLのまま返すとフロントエンドで表示崩れや計算エラーが発生する。COALESCE(price, 0) * COALESCE(quantity, 0) のようにデフォルト値で保護すること。
実務コラム:NULLはいつ使い、いつ使わないべきか
NULLは「値が存在しない・未登録」を表せる便利な仕組みですが、乱用するとアプリ全体でNULLガード処理が必要になりコードが複雑化します。実務での考え方は、「任意項目(登録しなくてよい項目)はNULL許可、必須項目はNOT NULL制約を付けてDB側で保証する」というルールが基本です。また、NULLと空文字列('')は別物なので、どちらで「未入力」を表すかをDB設計時に統一しておくことも重要です。
QUESTION 3

LIKE / ILIKE — 文字列パターン検索で検索APIを実装する

LIKEILIKE検索APIパターンマッチ
前提知識

LIKE は文字列の部分一致・前方一致・後方一致を行う演算子です。ILIKE(PostgreSQL拡張)は大文字・小文字を区別しないLIKEです。検索API(GET /products?q=キーワード)の基本実装です。

WHERE col LIKE  '%keyword%'     -- 部分一致(% = 0文字以上の任意文字列)
WHERE col LIKE  'keyword%'      -- 前方一致(keywordで始まる)
WHERE col LIKE  '%keyword'      -- 後方一致(keywordで終わる)
WHERE col LIKE  'k_yword'       -- _ = 任意の1文字(1文字ワイルドカード)
WHERE col ILIKE '%Keyword%'     -- 大文字/小文字を区別しない部分一致(PostgreSQL)
パフォーマンス注意:LIKE '%keyword%'(前方にも%)はインデックスが使えず全件スキャンになる。大規模テーブルでの本格的な全文検索には、pg_trgm拡張のGINインデックスや全文検索エンジン(Elasticsearch等)を検討すること。
問題

products テーブルから、name に 'コーヒー' を含む商品を price の安い順で取得してください(大文字小文字区別なし)。また、SKUコード(sku)が 'BEV-' で始まる商品数を別のクエリで取得してください。

使用テーブル
▸ products
product_idnameskuprice
1アイスコーヒーBEV-001350
2緑茶BEV-002250
3ホットコーヒーBEV-003380
4チョコレートケーキFOOD-001480
5コーヒーゼリーFOOD-002320
6オレンジジュースBEV-004280
期待出力

期待する出力①:

product_idnameskuprice
5コーヒーゼリーFOOD-002320
1アイスコーヒーBEV-001350
3ホットコーヒーBEV-003380

期待する出力②:

bev_count
4
模範解答コード
-- ① name に 'コーヒー' を含む商品を price 昇順で取得
SELECT
  product_id,
  name,
  sku,
  price
FROM   products
WHERE  name ILIKE '%コーヒー%'  -- 大小文字を区別しない部分一致(PostgreSQL)
ORDER BY price ASC;

-- ② sku が 'BEV-' で始まる商品数をカウント
SELECT
  COUNT(*) AS bev_count
FROM   products
WHERE  sku LIKE 'BEV-%';  -- 前方一致('BEV-' で始まる)

/*
  実行順序(① ILIKE部分一致クエリ):
  1. FROM products            → 行を読み込む
  2. WHERE name ILIKE ...     → 行を絞り込む
  3. SELECT                   → 列を評価
  4. ORDER BY price ASC       → 並び替えて出力

  実行順序(② LIKE前方一致カウントクエリ):
  1. FROM products            → 行を読み込む
  2. WHERE sku LIKE 'BEV-%'   → 行を絞り込む
  3. SELECT bev_count         → 集計関数を評価
*/
解説(テーブル変化・ポイント)
SELECT product_id, name, sku, price FROM products WHERE name ILIKE '%コーヒー%' ORDER BY price ASC;
LEGEND
データ取得・読込対象
① FROM
FROM productsproducts テーブル全体(6行)を読み込みます。次の WHERE で name に 'コーヒー' を含む行に絞り込みます。
1 / 3
product_idnameskuprice
1アイスコーヒーBEV-001350
2緑茶BEV-002250
3ホットコーヒーBEV-003380
4チョコレートケーキFOOD-001480
5コーヒーゼリーFOOD-002320
6オレンジジュースBEV-004280
全 6行 読込
SELECT COUNT(*) AS bev_count FROM products WHERE sku LIKE 'BEV-%';
LEGEND
データ取得・読込対象
① FROM
FROM productsproducts テーブル全体(6行)を読み込みます。次の WHERE で sku が 'BEV-' で始まる行に絞り込みます。
1 / 3
product_idnameskuprice
1アイスコーヒーBEV-001350
2緑茶BEV-002250
3ホットコーヒーBEV-003380
4チョコレートケーキFOOD-001480
5コーヒーゼリーFOOD-002320
6オレンジジュースBEV-004280
全 6行 読込
学習ポイント
超基礎:% と _ の違い:% は「0文字以上の任意文字列」、_ は「任意の1文字だけ」に対応します。例えば 'C_T' は "CAT" や "CUT" にはマッチしますが "COAT" にはマッチしません。
検索APIの典型パターン:クエリパラメータ ?q=コーヒー を受け取ったサーバーが WHERE name ILIKE '%' || $1 || '%' のようにプレースホルダーを使ってSQLに組み込む。この際SQLインジェクション対策としてプレースホルダー(バインド変数)は必須。
LIKE と ILIKE の使い分け:日本語は大文字小文字の区別がないため両方同じ結果になることが多い。英語のデータ(メールアドレス、SKUコードなど)で大文字小文字を無視したい場合に ILIKE が有効。標準SQLではなくPostgreSQL拡張なので他のDBでは LOWER(col) LIKE LOWER('%key%') と書くことが多い。
インデックスが使える条件:前方一致 'prefix%' はB-treeインデックスが使える場合がある。しかし両端に % がある部分一致 '%keyword%' は通常インデックスが使えない。大量データの検索には pg_trgm(トライグラムインデックス)の利用を検討すること。
アンチパターン
文字列連結でSQLを組み立てる(SQLインジェクション):"WHERE name LIKE '%" + userInput + "%'" のようにユーザー入力を直接埋め込むのは危険。'; DROP TABLE products; -- のような入力で任意SQLが実行されてしまう。必ずプレースホルダー($1 等)を使うこと。
大量データへの % 両端LIKE:数百万件のテーブルに LIKE '%keyword%' をかけると全件スキャンになりAPIがタイムアウトする。本格的な全文検索は pg_trgm + GINインデックスや別途全文検索エンジンを導入すること。
実務コラム:LIKEの限界と全文検索エンジンへの移行タイミング
小規模サービス(数万件以下)ではLIKEで十分ですが、データが数十万件を超えてくると部分一致検索がスロークエリになりやすくなります。まず pg_trgm(PostgreSQLのトライグラム拡張)を試し、それでも追いつかなければ Elasticsearch や Meilisearch などの専用全文検索エンジンをDBと並列で運用する構成に移行するのが実務の定石です。「今のユーザー数や件数でLIKEが十分か?」を定期的に見直す習慣が大切です。
QUESTION 4

IN / NOT IN / EXISTS — 集合演算子でバッチ対象の効率的な絞り込み

INNOT INEXISTSバッチ絞込
前提知識

IN は列の値が指定したリストまたはサブクエリの結果セットに含まれるかを判定します。NOT IN は含まれない行を取得します。EXISTS はサブクエリが1件以上の行を返すかどうかをチェックします。

WHERE col IN ('a', 'b', 'c')      -- col が 'a' 'b' 'c' のいずれかに一致
WHERE col IN (SELECT id FROM t)  -- サブクエリの結果セットに含まれる
WHERE col NOT IN ('x', 'y')      -- col が 'x' 'y' のいずれでもない
WHERE EXISTS (                    -- サブクエリが1件以上ヒットするか確認
  SELECT 1 FROM t
  WHERE  t.id = outer.id           -- 外側クエリと内側クエリを相関させる
)
NOT IN と NULL の罠:NOT IN (SELECT ...) のサブクエリ結果に NULL が1件でも含まれると、すべての行がFALSEになり0件ヒットになる。NOT INのサブクエリには必ず WHERE col IS NOT NULL を付けるか、NOT EXISTS を使うことを推奨。
問題

ordersテーブルから status が 'completed' または 'pending' の注文を取得してください。② 同テーブルから purchases テーブルに purchase_id が存在しない(未処理の)注文を取得してください。

使用テーブル
▸ orders
order_iduser_idamountstatus
1U013000completed
2U025000pending
3U018000cancelled
4U032000completed
5U021200pending
▸ purchases(処理済み注文)
purchase_idprocessed_at
12024-05-10
42024-05-11
期待出力

期待する出力①:

order_iduser_idamountstatus
1U013000completed
2U025000pending
4U032000completed
5U021200pending

期待する出力②:

order_iduser_idamountstatus
2U025000pending
3U018000cancelled
5U021200pending
模範解答コード
-- ① status が completed または pending の注文を取得(IN を使用)
SELECT
  order_id,
  user_id,
  amount,
  status
FROM   orders
WHERE  status IN ('completed', 'pending')  -- いずれかに一致(OR を IN で簡潔に)
ORDER BY order_id;

-- ② purchases テーブルに存在しない注文(NOT EXISTS を使用)
SELECT
  o.order_id,
  o.user_id,
  o.amount,
  o.status
FROM   orders o
WHERE  NOT EXISTS (                  -- サブクエリが0件=存在しない注文
  SELECT 1                           -- 存在確認なので値は何でもよい
  FROM   purchases p
  WHERE  p.purchase_id = o.order_id  -- 外側と相関(相関サブクエリ)
)
ORDER BY o.order_id;

/*
  実行順序(① IN クエリ):
  1. FROM orders          → 行を読み込む
  2. WHERE status IN (...) → 行を絞り込む
  3. SELECT               → 列を評価
  4. ORDER BY order_id    → 並び替えて出力

  実行順序(② NOT EXISTS クエリ):
  1. FROM orders o        → 行を読み込む
  2. WHERE NOT EXISTS     → サブクエリを評価して行を絞り込む
  3. SELECT               → 列を評価
  4. ORDER BY o.order_id  → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT order_id, user_id, amount, status FROM orders WHERE status IN ('completed', 'pending') ORDER BY order_id;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全体(5行)を読み込みます。status は completed/pending/cancelled の3種類があります。
1 / 3
order_iduser_idamountstatus
1U013000completed
2U025000pending
3U018000cancelled
4U032000completed
5U021200pending
全 5行 読込
SELECT o.order_id, o.user_id, o.amount, o.status FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM purchases p WHERE p.purchase_id = o.order_id ) ORDER BY o.order_id;
LEGEND
データ取得・読込対象
① FROM orders
FROM orders o外側クエリの対象テーブル orders 全体(5行)を読み込みます。各行に対してサブクエリで purchases 内の存在確認を行います。
1 / 3
order_iduser_idamountstatus
1U013000completed
2U025000pending
3U018000cancelled
4U032000completed
5U021200pending
全 5行 読込
学習ポイント
超基礎:IN の代替表現:WHERE status IN ('completed', 'pending')WHERE status = 'completed' OR status = 'pending' と同じ意味です。INを使うとOR条件が増えても読みやすく保てます。
バッチ処理での未処理レコード抽出:定期バッチで「まだ処理されていない注文を取得して処理する」ときに NOT EXISTS が大活躍。処理済みテーブルに存在しない行を効率よく抽出できる。
NOT IN より NOT EXISTS を推奨する理由:NOT IN (SELECT purchase_id FROM purchases) は purchases に NULL が含まれると全件0件になる。NOT EXISTS はNULLの影響を受けないため安全。実務では NOT EXISTS または LEFT JOIN + IS NULL が推奨される。
EXISTS の SELECT 1 の意味:EXISTS はサブクエリが「1件以上ヒットするか」だけを確認するので、返す列の値は何でもよい。慣習として SELECT 1SELECT NULL が書かれる(意味は同じ)。
アンチパターン
NOT IN のサブクエリに NULL が含まれる:WHERE order_id NOT IN (SELECT purchase_id FROM purchases) の purchases に NULL があると「order_id != NULL」が常にFALSEになり結果が0件になる。WHERE purchase_id IS NOT NULL をサブクエリに追加するか NOT EXISTS を使うこと。
IN に膨大なリストを渡す:WHERE id IN (1,2,3,...,10000) のように数万件のリストをIN句に直接書くと、SQLが非常に長くなりパース処理が重くなる。この場合は一時テーブルに INSERT してJOINする方が効率的。
実務コラム:差分バッチ処理でのNOT EXISTSの活用
夜間バッチで「前回処理済みでまだ未処理のレコードだけを取り出して処理する」差分抽出はシステム開発の頻出パターンです。処理済みIDを管理するテーブル(processed_ids等)を用意し、NOT EXISTSで未処理行を抽出する設計が安定して使われます。処理完了後に processed_ids に INSERT することで、再実行しても同じレコードを二重処理しない冪等な(べきとうな)バッチを実装できます。
QUESTION 5

日付・時刻関数 — DATE_TRUNC / EXTRACT / NOW() で日次・月次集計APIを作る

DATE_TRUNCEXTRACTNOW()日次集計
前提知識

日付・時刻関数はログの期間集計やバッチスケジュール処理に不可欠です。PostgreSQLの主要関数を押さえましょう。

NOW()                             -- 現在のタイムスタンプ(タイムゾーン付き)
CURRENT_DATE                      -- 今日の日付(時刻なし)
DATE_TRUNC('month', col)          -- colの月の先頭日時に切り捨て(年/月/日/hourも可)
EXTRACT(YEAR FROM col)           -- colから「年」だけ取り出す(MONTH, DAY, DOW等も可)
col ::DATE                        -- タイムスタンプを日付型にキャスト
col + INTERVAL '7 days'          -- 7日後のタイムスタンプ
col - INTERVAL '1 month'          -- 1ヶ月前のタイムスタンプ
日付範囲の指定:当月全体を取るには DATE_TRUNC('month', NOW()) 以上 かつ DATE_TRUNC('month', NOW()) + INTERVAL '1 month' 未満 とすると確実。
問題

orders テーブルから、月ごとの注文件数と合計金額を集計してください。月は 'YYYY-MM' 形式で表示し、新しい月を先に表示してください。また、過去30日以内の注文のみを返す別のクエリも作成してください。

使用テーブル
▸ orders
order_iduser_idamountordered_at
1U0130002024-04-05
2U0250002024-04-20
3U0180002024-05-10
4U0320002024-05-15
5U0212002024-05-28
6U0145002024-06-03
期待出力

期待する出力①:

monthorder_counttotal_amount
2024-0614500
2024-05311200
2024-0428000

期待する出力②:

order_iduser_idamountordered_at
6U0145002024-06-03
5U0212002024-05-28
4U0320002024-05-15
3U0180002024-05-10
模範解答コード
-- ① 月ごとの注文件数と合計金額を集計(新しい月順)
SELECT
  TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month,  -- 月初に切り捨て→'YYYY-MM' 文字列に
  COUNT(*)    AS order_count,
  SUM(amount) AS total_amount
FROM     orders
GROUP BY DATE_TRUNC('month', ordered_at)   -- 月単位でグループ化
ORDER BY DATE_TRUNC('month', ordered_at) DESC;  -- 新しい月順

-- ② 過去30日以内の注文のみ取得
SELECT
  order_id,
  user_id,
  amount,
  ordered_at
FROM   orders
WHERE  ordered_at >= DATE '2024-06-03' - INTERVAL '30 days'  -- 直近30日
ORDER BY ordered_at DESC;

/*
  実行順序(① 月次集計クエリ):
  1. FROM orders            → 行を読み込む
  2. GROUP BY DATE_TRUNC(月) → グループ化
  3. SELECT                 → 集計関数を評価(COUNT, SUM)
  4. ORDER BY ... DESC      → 並び替えて出力

  実行順序(② 過去30日クエリ):
  1. FROM orders            → 行を読み込む
  2. WHERE ordered_at >= ...→ 行を絞り込む
  3. SELECT                 → 列を評価
  4. ORDER BY ordered_at DESC → 並び替えて出力
*/
解説(テーブル変化・ポイント)
SELECT TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY DATE_TRUNC('month', ordered_at) ORDER BY DATE_TRUNC('month', ordered_at) DESC;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全体(6行)を読み込みます。ordered_at には2024年4月〜6月のデータが混在しています。
1 / 4
order_iduser_idamountordered_at
1U0130002024-04-05
2U0250002024-04-20
3U0180002024-05-10
4U0320002024-05-15
5U0212002024-05-28
6U0145002024-06-03
全 6行 読込
SELECT order_id, user_id, amount, ordered_at FROM orders WHERE ordered_at >= DATE '2024-06-03' - INTERVAL '30 days' ORDER BY ordered_at DESC;
LEGEND
データ取得・読込対象
① FROM
FROM ordersorders テーブル全体(6行)を読み込みます。WHERE で「現在時刻から30日以内」の行のみに絞り込みます。
1 / 3
order_iduser_idamountordered_at
1U0130002024-04-05
2U0250002024-04-20
3U0180002024-05-10
4U0320002024-05-15
5U0212002024-05-28
6U0145002024-06-03
全 6行 読込
学習ポイント
超基礎:DATE_TRUNC の切り捨てのしくみ:DATE_TRUNC('month', '2024-05-15')2024-05-01 00:00:00 になります。同じ月のデータは全て同じ値に切り捨てられるため、GROUP BY のキーとして使えます。'year'/'week'/'day'/'hour'等にも対応。
月次KPIダッシュボードAPIの基本形:このパターンで「月別売上推移」「月別アクティブユーザー数推移」などのグラフ用データを1クエリで生成できる。フロントエンドのチャートライブラリ(Chart.js等)に渡すデータとして頻繁に使われる。
EXTRACT と DATE_TRUNC の使い分け:EXTRACT は「値を数値として取り出す」用途(例: EXTRACT(MONTH FROM ordered_at)→5)。DATE_TRUNC は「タイムスタンプを切り捨てて同じ単位の集計キーを作る」用途。GROUP BYには DATE_TRUNC が適している。
INTERVAL での相対日付:NOW() - INTERVAL '30 days' はコードに日付をハードコードせず済むため、毎日実行するバッチに最適。'1 month''1 year''6 hours' 等も使える。
アンチパターン
列への関数適用でインデックスが使えなくなる:WHERE EXTRACT(YEAR FROM ordered_at) = 2024 はordered_at列への関数適用のため、インデックスが無効になる。代わりに WHERE ordered_at >= '2024-01-01' AND ordered_at < '2025-01-01' と範囲で書くとインデックスが使える。
タイムゾーンを無視したタイムスタンプ比較:サーバーのタイムゾーンとDBのタイムゾーンが異なる場合、「今日0時より後」という条件が期待通りに動かないことがある。TIMESTAMPTZ(タイムゾーン付き)型を使い、タイムゾーンを明示した比較(AT TIME ZONE 'Asia/Tokyo'等)を行うこと。
実務コラム:バッチの実行時刻とNOW()のズレ
夜間バッチで「前日のデータを集計する」場合、CURRENT_DATE - INTERVAL '1 day' のように相対日付を使うのが標準です。ただし、バッチが深夜0時をまたいで実行された場合、NOW()で取得される「今日」が変わってしまい集計対象がズレることがあります。このため、バッチ開始時点の「処理基準日時」を変数として受け取り、それをWHERE句に使う設計(バッチパラメーター化)が堅牢なアーキテクチャとして推奨されます。