SQL JOIN — 結合パターンと行数変化の応用

応用JOIN応用結合パターンWeb開発PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

HAVING — GROUP BY で集計した後、注文2件以上のユーザーだけを絞り込む

INNER JOINGROUP BYHAVING集計フィルタ
前提知識

基礎編で学んだ GROUP BY + 集計関数の次のステップが HAVING です。HAVING は GROUP BY 後の集計結果に条件をかけるための句で、集計関数(COUNT・SUM など)を条件式に使えます。

SELECT 列名, COUNT(...), SUM(...)
FROM users AS u
INNER JOIN orders AS o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name
HAVING COUNT(o.order_id) >= 2;
WHERE と HAVING の根本的な違い:
WHERE は JOIN後・GROUP BY前に個々の行を評価します。集計関数は使えません。
HAVING は GROUP BY後に各グループの集計結果を評価します。集計関数が使えます。
SQL の実行順序: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
問題

usersorders を結合し、注文件数が2件以上のユーザーの名前・注文件数・合計金額を取得してください。合計金額の多い順で出力します。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ orders
order_iduser_idamount
113000
211500
325000
432000
53800
期待出力
nameorder_counttotal_amount
田中24500
山田22800
模範解答コード
SELECT
  u.name,
  COUNT(o.order_id)  AS order_count,  -- NULL以外の行を数える
  SUM(o.amount)      AS total_amount  -- 注文金額を合計する
FROM users AS u
INNER JOIN orders AS o
  ON u.user_id = o.user_id
GROUP BY u.user_id, u.name     -- SELECT の非集計列を全列列挙する
HAVING  COUNT(o.order_id) >= 2  -- GROUP BY後の集計値で絞り込む
ORDER BY total_amount DESC;

/*
  実行順序:
  1. FROM users AS u             → users を読み込む
  2. INNER JOIN orders AS o      → ON で結合
  3. GROUP BY u.user_id          → ユーザーでグループ化
  4. COUNT / SUM                 → 各グループで集計
  5. HAVING COUNT(...)           → 件数2以上を残す
  6. SELECT u.name, ...          → 列を射影
  7. ORDER BY total_amount DESC  → 合計の降順でソート
  */
解説(テーブル変化・ポイント)
SELECT u.name, COUNT(o.order_id) AS order_count, SUM(o.amount) AS total_amount FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name HAVING COUNT(o.order_id) >= 2 ORDER BY total_amount DESC;
LEGEND
データ取得・読込対象
① FROM
FROM users AS uusers テーブル(3行)を左テーブルとして読み込みます。この後 orders と JOIN し、集計・HAVING フィルタへと進みます。
1 / 5
user_idname
1田中
2佐藤
3山田
全 3行 読込
学習ポイント
JOIN PATTERN
INNER JOIN → GROUP BY → HAVING
JOIN後に集計し、集計値でグループを絞り込む
u = users(左)  o = orders(右)
WHERE vs HAVING — 適用タイミングの違い:SQL の評価順序は FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BYWHERE は GROUP BY のに個々の行を評価するため集計関数が使えません。HAVING は GROUP BY のに各グループの集計値を評価するため COUNTSUM などをそのまま条件に書けます。WHERE COUNT(...) >= 2 と書くとエラーになります。
HAVING の複合条件と WHERE との組み合わせ:HAVING 句は AND・OR で複数の集計条件を組み合わせられます。例えば「2件以上かつ合計5,000円以上」なら HAVING COUNT(...) >= 2 AND SUM(...) >= 5000 と書けます。さらに WHERE で集計前フィルタ、HAVING で集計後フィルタ、という2段階の絞り込みも実務で頻出のパターンです。
アンチパターン
WHERE に集計関数を書いてしまう:WHERE COUNT(o.order_id) >= 2 と書くとエラーになります。集計値による絞り込みには必ず HAVING を使います。逆に特定ユーザーへの絞り込みは HAVING でも動きますが、GROUP BY 前に絞り込む WHERE に書く方がパフォーマンス上有利です。
SELECT のエイリアス名を HAVING に使う(PostgreSQL ではエラー):SELECT COUNT(o.order_id) AS cnt ... HAVING cnt >= 2 は PostgreSQL ではエラーになります(MySQL は許容)。HAVING は SELECT より先に評価されるためエイリアスがまだ存在しません。
実務コラム
「ロイヤルカスタマーリストをAPIで返す」「月間アクティブユーザーを集計する」など、条件付き集計はレポートAPIで最も頻繁に使われるパターンです。WHERE で集計前フィルタ → GROUP BY で集計 → HAVING で集計後フィルタの3段階を意識すると、どの条件をどこに書くべきかが自然に判断できるようになります。
QUESTION 2

LEFT JOIN + COALESCE — 注文0件のユーザーも含め、全ユーザーの集計結果を返す

LEFT JOINCOALESCECOUNT(列名)NULL変換
前提知識

「注文ゼロのユーザーも 0 として一覧に含めたい」という要件は、LEFT JOIN + COALESCE の組み合わせで解決します。基礎編で学んだ LEFT JOIN の NULL 補完を、集計関数と COALESCE でさらに実用的に活用します。

SELECT
  u.name,
  COUNT(o.order_id)          AS order_count,    -- NULLをスキップして0を返す
  COALESCE(SUM(o.amount), 0) AS total_amount    -- SUM(NULL)→NULL を 0 に変換
FROM users AS u
LEFT JOIN orders AS o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name;
COUNT(列名) と COUNT(*) の違い:
COUNT(o.order_id)NULL を無視 して数えるため、注文0件ユーザーは自動で 0 になります。
COUNT(*) は行数を数えるため、LEFT JOIN で補完された NULL 行も 1 としてカウントしてしまいます。
LEFT JOIN 後の集計では 必ず COUNT(右テーブルの列名) を使います。
問題

users テーブルの全ユーザーについて、注文件数(order_count)と合計金額(total_amount)を取得してください。注文が0件のユーザーは 0 として表示します。合計金額の降順で出力します。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ orders
order_iduser_idamount
113000
211500
325000
期待出力
nameorder_counttotal_amount
佐藤15000
田中24500
山田00
模範解答コード
SELECT
  u.name,
  COUNT(o.order_id)          AS order_count,  -- COUNT(列名)はNULLをスキップ → 0件ユーザーは自動で0になる
  COALESCE(SUM(o.amount), 0) AS total_amount  -- SUM(NULL) = NULL のため COALESCE で 0 に変換する
FROM users AS u
LEFT JOIN orders AS o                         -- 全ユーザーを保持(山田は NULL 補完)
  ON u.user_id = o.user_id
GROUP BY u.user_id, u.name                    -- NULL 行も含めてグループ化する
ORDER BY total_amount DESC;

/*
  実行順序:
  1. FROM users AS u             → users を読み込む
  2. LEFT JOIN orders AS o       → ON で照合・NULL 補完
  3. GROUP BY u.user_id, u.name  → ユーザーでグループ化
  4. COUNT / COALESCE(SUM,0)     → 件数・合計(NULLは0補正)
  5. SELECT / ORDER BY           → total_amount DESC で出力
  */
解説(テーブル変化・ポイント)
SELECT u.name, COUNT(o.order_id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM users AS u LEFT JOIN orders AS o ON u.user_id = o.user_id GROUP BY u.user_id, u.name ORDER BY total_amount DESC;
LEGEND
データ取得・読込対象
① FROM
FROM users AS uusers テーブル(3行)を左テーブルとして読み込みます。LEFT JOIN のため全行が必ず結果に含まれます。
1 / 5
user_idname
1田中
2佐藤
3山田
全 3行 読込(全ユーザーが保持される)
学習ポイント
JOIN PATTERN
LEFT JOIN + COALESCE — 全件集計(NULL → 0)
左テーブル全行保持、0件ユーザーをNULLから0に変換
u = users(左)  o = orders(右)
COUNT(列名) は NULL をスキップする:COUNT(o.order_id)NULL を数えない ため、LEFT JOIN で NULL 補完された山田のグループは自動で 0 になります。一方 COUNT(*) は行の存在をカウントするため NULL 行も 1 として数えてしまいます。LEFT JOIN 後の集計では右テーブルの NOT NULL な列(主キーなど)を COUNT の引数に使うのが正しいパターンです。
COALESCE(SUM(o.amount), 0) の仕組み:SUM は集計対象が全て NULL の場合に NULL を返します。COALESCE(値, デフォルト) は最初の非 NULL 値を返す関数なので COALESCE(NULL, 0) = 0 となります。なお SUM(COALESCE(o.amount, 0)) と順序を逆にすると「NULL の amount を 0 として合計に含める」別の意味になるため、COALESCE で SUM 全体を包む順序が重要です。
アンチパターン
COUNT(*) でカウントすると0件ユーザーが1件になる:山田の LEFT JOIN 補完行は NULL 行でも「1行」として存在しています。COUNT(*) を使うと山田の order_count が 1 になり誤った集計になります。LEFT JOIN を使う集計では必ず COUNT(右テーブルの列名) を使う習慣をつけましょう。
LEFT JOIN 後に WHERE で右テーブルを絞ると INNER JOIN 相当になる:LEFT JOIN ... WHERE o.amount > 0 のように書くと、NULL 行(山田)は NULL > 0 が FALSE になり除外されます。LEFT JOIN の効果を保ったまま右テーブルに条件をかけたい場合は ON 句に書きます。
実務コラム
管理画面のダッシュボードで「全ユーザーの購入件数・累計金額を一覧表示(未購入は0)」というAPIは、LEFT JOIN + COUNT(列名) + COALESCE(SUM, 0) が定番パターンです。INNER JOIN にすると未購入ユーザーが消えてしまい、フロントエンドで「全ユーザー数と集計行数が合わない」バグの原因になります。
QUESTION 3

SELF JOIN — 同じテーブルを2つのエイリアスで結合し、上司と部下の名前を取得する

INNER JOIN自己結合階層構造隣接リスト
前提知識

SELF JOIN(自己結合)は、同じテーブルを2回 JOIN する操作です。テーブルに別々のエイリアスを付けることで、同一テーブル内の行同士の関係(上司と部下、親カテゴリと子カテゴリ)を表現できます。

FROM   employees AS e            -- e = 部下ロール
INNER JOIN employees AS m      -- m = 上司ロール(同じテーブル)
  ON e.manager_id = m.emp_id;     -- 部下のmanager_idが上司のemp_idと一致
隣接リストモデル(Adjacency List):テーブルに manager_id のような自分自身のテーブルを参照する外部キー(自己参照)を持たせることで、階層構造を1テーブルで表現できます。SELF JOIN はこの構造から親・子の関係を取得するための定番テクニックです。
問題

employees テーブルから、各社員の名前(employee)と直属の上司の名前(manager)を取得してください。上司がいない社員(社長:田中)は除外します

使用テーブル(employees — 1テーブルのみ)
▸ employees
emp_idnamemanager_id
1田中NULL
2佐藤1
3山田1
4鈴木2
5高橋2
期待出力
employeemanager
佐藤田中
山田田中
鈴木佐藤
高橋佐藤
模範解答コード
SELECT
  e.name AS employee,             -- 部下側エイリアス e の name
  m.name AS manager               -- 上司側エイリアス m の name
FROM employees AS e
INNER JOIN employees AS m         -- 同じテーブルを別ロールで結合: e.manager_id = m.emp_id で上司を特定
  ON e.manager_id = m.emp_id      -- manager_id=NULL(田中)は ON条件不成立 → 除外
ORDER BY e.manager_id, e.emp_id;

/*
  実行順序:
  1. FROM employees AS e       → 全5行を「部下ロール」として読み込む
  2. INNER JOIN employees AS m → 各行の manager_id を emp_id として上司を照合
                                 manager_id=NULL(田中)はON条件を満たさないため除外
  3. SELECT e.name, m.name     → 部下名・上司名の2列を射影
  4. ORDER BY                  → manager_id → emp_id の順でソート
*/
解説(テーブル変化・ポイント)
SELECT e.name AS employee, m.name AS manager FROM employees AS e INNER JOIN employees AS m ON e.manager_id = m.emp_id ORDER BY e.manager_id, e.emp_id;
LEGEND
データ取得・読込対象
① FROM
FROM employees AS e(部下ロール)employees テーブル(5行)を「部下ロール(e)」として読み込みます。manager_id 列が SELF JOIN のキーです。manager_id=NULL(田中)は上司を持たない社長です。
1 / 4
emp_idnamemanager_id
1田中NULL
2佐藤1
3山田1
4鈴木2
5高橋2
全 5行 読込(同一テーブルを2ロールで参照する)
学習ポイント
JOIN PATTERN
SELF JOIN — 自己結合(同一テーブルの2ロール参照)
同じテーブルを別エイリアスで結合し、行間の関係を取得
e = 部下ロール   m = 上司ロール(どちらも employees)
SELF JOIN の本質 — 同じテーブルを2つのロールで参照する:物理的には1つのテーブルですが、別エイリアスを付けることで SQL エンジンは「2つの異なるテーブル」として扱います。e(部下)の manager_idm(上司)の emp_id を ON 句でつなぐことで、「部下の上司は誰か」という行間の関係が取得できます。INNER JOIN のため manager_id=NULL(社長)は自動的に除外されます。
隣接リストモデルと再帰クエリ:本問の employees テーブルのように「自分自身のテーブルを参照する外部キー」を持つ構造を隣接リストモデルといいます。SELF JOIN で取得できるのは直属1階層のみです。孫・曾孫など任意の深さを辿るには WITH RECURSIVE(再帰 CTE)が必要で、PostgreSQL・MySQL 8.0以降で利用できます。
アンチパターン
上司がいないトップレベルも含めたい場合は LEFT JOIN を使う:本問は INNER JOIN のため manager_id=NULL(田中=社長)が除外されます。「社長も含めて全員の上司名を表示(社長は manager 列が NULL)」にしたい場合は LEFT JOIN employees AS m ON e.manager_id = m.emp_id に変えるだけで実現できます。
3階層以上を SELF JOIN の連鎖で取得しようとする:孫の上司(祖父)まで取得するには SELF JOIN をさらに連鎖させる必要があり、階層が深いほど JOIN の連鎖が増えて非実用的になります。可変深さを辿る階層ツリーには WITH RECURSIVE を使うのが現代SQLの正解です。
実務コラム
SELF JOIN が使われる実務シーン: 組織図API(部下一覧とその直属の上司名を返す)、商品カテゴリ(Electronics → Laptops → Gaming Laptops という親子階層ナビゲーション)、フォーラムのスレッド(コメントの reply_to_comment_id による親子関係)。3階層以上の場合は WITH RECURSIVE に発展しますが、直属1階層のうちは SELF JOIN が最もシンプルな解法です。
QUESTION 4

JOIN + CASE式 — 注文ステータスを日本語ラベルに変換しながらユーザー名と結合する

INNER JOINCASE式ラベル変換ステータス管理
前提知識

CASE式は SQL の条件分岐式で、列の値に応じて異なる値を返せます。JOIN と組み合わせることで、結合と同時に値の変換・ラベル付けができます。

SELECT
  列名,
  CASE 列名                        -- 単純CASE式: 列の値で分岐
    WHEN 'pending'   THEN '処理中'
    WHEN 'shipped'   THEN '発送済み'
    ELSE             '不明'         -- 未定義ステータスへのフォールバック
  END AS status_label
単純CASE式 vs 検索CASE式:
CASE 列名 WHEN 値 THEN ...(単純): 列の値と定数を = で比較します。本問のように列挙した値に対応する変換に向いています。
CASE WHEN 条件式 THEN ...(検索): 任意の条件式(>LIKEIS NULL 等)が使えます。範囲分類や複合条件に向いています。
問題

orders テーブルと users テーブルを結合し、注文一覧を取得してください。status 列の英語値を CASE式で日本語ラベル(status_label)に変換して出力します。

使用テーブル
▸ orders
order_iduser_idamountstatus
113000shipped
211500pending
325000delivered
432000cancelled
53800pending
▸ users
user_idname
1田中
2佐藤
3山田
期待出力
order_iduser_nameamountstatus_label
1田中3000発送済み
2田中1500処理中
3佐藤5000配達完了
4山田2000キャンセル
5山田800処理中
模範解答コード
SELECT
  o.order_id,
  u.name AS user_name,
  o.amount,
  CASE o.status                    -- 単純CASE式: o.status の値で分岐して日本語ラベルに変換する
    WHEN 'pending'   THEN '処理中'
    WHEN 'shipped'   THEN '発送済み'
    WHEN 'delivered' THEN '配達完了'
    WHEN 'cancelled' THEN 'キャンセル'
    ELSE             '不明'          -- 未定義ステータスへのフォールバック
  END AS status_label
FROM orders AS o
INNER JOIN users AS u
  ON o.user_id = u.user_id
ORDER BY o.order_id;

/*
  実行順序:
  1. FROM orders AS o          → 注文5行を読み込む
  2. INNER JOIN users AS u     → user_id で照合し user_name を付与
  3. CASE o.status ...         → 各行の status 値を評価し日本語ラベルに変換
  4. SELECT / ORDER BY         → 4列を order_id 昇順で出力
*/
解説(テーブル変化・ポイント)
SELECT o.order_id, u.name AS user_name, o.amount, CASE o.status WHEN 'pending' THEN '処理中' WHEN 'shipped' THEN '発送済み' WHEN 'delivered' THEN '配達完了' WHEN 'cancelled' THEN 'キャンセル' ELSE '不明' END AS status_label FROM orders AS o INNER JOIN users AS u ON o.user_id = u.user_id ORDER BY o.order_id;
LEGEND
データ取得・読込対象
① FROM
FROM orders AS oorders テーブル(5行)を起点として読み込みます。status 列が CASE式の評価対象になります。
1 / 5
order_iduser_idamountstatus
113000shipped
211500pending
325000delivered
432000cancelled
53800pending
全 5行 読込(status列を含む)
学習ポイント
JOIN PATTERN
INNER JOIN + CASE式 — 結合とラベル変換の同時実行
JOIN で名前を取得しながら CASE式でステータスを変換
o = orders(左)  u = users(右)
単純CASE式 vs 検索CASE式の使い分け:本問の CASE o.status WHEN '...' THEN '...'単純CASE式で、指定した列の値と定数を = で比較します。一方 CASE WHEN o.amount > 5000 THEN '高額' WHEN o.amount > 1000 THEN '中額' ELSE '少額' END のような検索CASE式は任意の条件式が使えます。範囲・複合条件には検索 CASE を使います。
CASE式は SELECT 以外でも使える:CASE式は SELECT 句だけでなく ORDER BYGROUP BYWHERE、集計関数の引数(例: COUNT(CASE WHEN status='shipped' THEN 1 END) で発送済みのみカウント)にも使えます。SQL の中で最も汎用的な式の一つです。
アンチパターン
アプリ側で変換するとN+1問題につながる:「SQLでは英語のまま取得し、アプリで日本語に変換する」実装も機能しますが、全件取得後にループ処理するためデータ量に比例して遅くなります。またステータスの定義がアプリコードとSQLの両方に散在してメンテナンスコストが高まります。変換ロジックはSQLの CASE式に集約するのが原則です。
ELSE を省略すると未定義ステータスが NULL になる:CASE式で ELSE を書かないと、どの WHEN にもマッチしない値は NULL を返します。本番データには想定外のステータス値が入り込むことがあるため、必ず ELSE '不明'を書いてフォールバックを明示しましょう。
実務コラム
注文ステータス・支払い状態など、DBには英語の定数値(enum)で保存し、APIレスポンスやUIには日本語表示するケースは実務で非常に多いです。JOIN + CASE式を組み合わせることで、1回のクエリで「誰が」「何を」「どんな状態で」を一括取得・変換でき、バックエンドAPIのコードがシンプルになります。
QUESTION 5

派生テーブルJOIN — サブクエリで集計した結果(派生テーブル)とユーザーを結合する

INNER JOIN派生テーブルサブクエリインラインビュー
前提知識

派生テーブル(Derived Table)は、FROM句や JOIN 句の中に書いたサブクエリを一時的なテーブルとして扱う技法です。サブクエリにエイリアスを付けることで、通常のテーブルと同様に JOIN できます。

FROM users AS u
INNER JOIN (                       -- ここから派生テーブル(サブクエリ)
  SELECT
    user_id,
    MAX(ordered_at) AS latest,
    SUM(amount)     AS total
  FROM orders
  GROUP BY user_id
) AS s                             -- 派生テーブルに別名 s を付ける
  ON u.user_id = s.user_id;
実行順序:派生テーブルは外部クエリより先に実行され、集計済みの「ミニテーブル」として扱われます。集計・変換を事前に済ませた結果と他のテーブルを JOIN できるため、複雑な集計クエリをシンプルに記述できます。
問題

orders テーブルを user_id でグループ集計し(最新注文日・合計金額)、その結果を users テーブルと JOIN してユーザー名と一緒に取得してください。出力は最新注文日の新しい順で並べます。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ orders
order_iduser_idamountordered_at
1130002024-01-10
2115002024-02-15
3250002024-01-20
4320002024-03-05
期待出力
namelatest_order_datetotal_amount
山田2024-03-052000
田中2024-02-154500
佐藤2024-01-205000
模範解答コード
SELECT
  u.name,
  s.latest_order_date,
  s.total_amount
FROM users AS u
INNER JOIN (                        -- FROM句のサブクエリ(派生テーブル)
  SELECT
    user_id,
    MAX(ordered_at) AS latest_order_date,  -- 最新注文日
    SUM(amount)     AS total_amount        -- 合計金額
  FROM orders
  GROUP BY user_id
) AS s                             -- 派生テーブルに別名 s を付ける
  ON u.user_id = s.user_id
ORDER BY s.latest_order_date DESC; -- 最新注文日の降順

/*
  実行順序:
  1. サブクエリ実行             → orders を GROUP BY user_id で集計
                                 派生テーブル s(3行)が生成される
  2. FROM users AS u           → users の3行を読み込む
  3. INNER JOIN ... AS s       → u.user_id と s.user_id を照合・結合(3行保持)
  4. SELECT u.name, s.*        → 必要な3列を射影
  5. ORDER BY latest_order_date DESC → 最新注文日の降順でソート
*/
解説(テーブル変化・ポイント)
SELECT u.name, s.latest_order_date, s.total_amount FROM users AS u INNER JOIN ( SELECT user_id, MAX(ordered_at) AS latest_order_date, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS s ON u.user_id = s.user_id ORDER BY s.latest_order_date DESC;
LEGEND
データ取得・読込対象
① サブクエリ: FROM orders
FROM ordersまず FROM句内のサブクエリが実行されます。対象となる orders テーブル(4行)を読み込みます。
1 / 6
order_iduser_idamountordered_at
1130002024-01-10
2115002024-02-15
3250002024-01-20
4320002024-03-05
サブクエリ実行開始: 4行読込
学習ポイント
JOIN PATTERN
INNER JOIN 派生テーブル — サブクエリを仮想テーブルとして結合
集計済みサブクエリ(s)を外部テーブル(u)と JOIN
u = users   s = 派生テーブル(orders 集計結果)
派生テーブルの実行順序:SQL エンジンは FROM句のサブクエリを外部クエリより先に実行します。サブクエリが集計済みの結果(3行)を返し、それを「テーブル s」として外部クエリが JOIN します。この仕組みにより「集計後の値でJOIN」というパターンが実現できます。なお派生テーブルは外部クエリの列を参照できない非相関サブクエリであるため、1回だけ実行されます。
CTE(WITH句)への発展:派生テーブルは WITH 句(Common Table Expression)で書き直すと可読性が上がります。WITH s AS (SELECT user_id, MAX(ordered_at) ... FROM orders GROUP BY user_id) SELECT ... FROM users JOIN s ... の形です。CTE はサブクエリと同じ結果を返しますが、名前を付けて先頭に定義できるため長いクエリでも構造が追いやすく、複数箇所で参照もできます。
アンチパターン
相関サブクエリで代替するとパフォーマンスが劣化する:SELECT u.name, (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) AS latest ... という書き方(相関サブクエリ)は外部クエリの各行ごとにサブクエリを実行するため、users が100万行あれば100万回実行されます。派生テーブル(または CTE)を使えば集計は1回で済みます。
派生テーブルにエイリアスを付け忘れるとエラーになる:INNER JOIN (SELECT ...) ON ... のようにサブクエリにエイリアスを付けないと、PostgreSQL・MySQL いずれもエラーになります。派生テーブルには必ず AS エイリアス名を付けてください。
実務コラム
「全ユーザーの最終ログイン日時と累計課金額をダッシュボードAPIで返す」など、集計サブクエリ → JOIN のパターンは実務で非常に多く登場します。慣れてきたら WITH 句(CTE)に書き換えると複雑なクエリでも読みやすくなります。さらに発展として、複数行の最新レコードを取得する LATERAL JOIN や ウィンドウ関数も実務クエリで頻出のパターンです。