【SQL】UNION完全ガイド|UNION ALL・INTERSECT・EXCEPT/MINUSの違いと実務パターン

SQLで複数のSELECT結果を縦に連結したいとき、UNION演算子を使います。ただ「結合する」だけでなく、重複を残すUNION ALL共通行を取り出すINTERSECT差分を取り出すEXCEPT/MINUSなど、集合演算には用途に応じた使い分けが必要です。

本記事ではUNIONの基本から、パフォーマンスへの影響、RDBMS間の違い、実務でよく使うパターンまで体系的に解説します。

この記事で分かること

  • UNION と UNION ALL の違いとパフォーマンス比較
  • INTERSECT・EXCEPT/MINUS の使い方(MySQL 8.0+/PostgreSQL/SQL Server)
  • 3テーブル以上のUNION・ORDER BY・LIMIT との組み合わせ
  • NULL の重複判定・列数・型の自動変換ルール
  • JOIN との根本的な違いと使い分け基準
  • 複数ソース統合・縦展開レポートなど実務パターン
スポンサーリンク

UNION の基本構文と動作

UNIONは2つ以上のSELECT文の結果を縦に積み重ねる演算子です。条件は「列数が同じ・対応する列の型が互換」の2点のみ。テーブルが同じである必要はありません。

UNION 基本構文
-- 顧客テーブルとリードテーブルを縦に統合
SELECT name, email, '顧客' AS source
FROM   customers
UNION
SELECT name, email, 'リード'
FROM   leads;

UNIONには重複排除が組み込まれています。両方のSELECTに同じ行が存在した場合、1行にまとめられます。重複排除が不要なら UNION ALL を使います。

列のルール:列名・型・NULL

項目 動作
列名 1つ目のSELECTの列名が結果セットに使われる
列数 全てのSELECTで一致が必須(違うとエラー)
RDBMS が暗黙的に変換(INT と VARCHAR は VARCHAR に昇格など)
NULL NULL同士は同じ値として扱われ重複排除される
列数を合わせる — NULLで補完
-- テーブルごとに列数が違う場合はNULLやリテラルで補う
SELECT product_id, product_name, price,    NULL AS category
FROM   products
UNION ALL
SELECT service_id,  service_name, monthly_fee, category
FROM   services;

UNION vs UNION ALL — パフォーマンスの差

迷ったら UNION ALL を選ぶ。重複が不要なら明示的に UNION。

UNIONは内部で一時テーブルへの全件書き込み → ソートまたはハッシュによる重複排除を行います。UNION ALLはそのまま結果を流すだけなので、常にUNION ALLの方が高速です。

比較項目 UNION UNION ALL
重複行 排除される そのまま残る
パフォーマンス 遅い(ソート or ハッシュが走る) 速い(追加コストなし)
NULL の重複 NULL同士は同一扱いで1行に NULLも複数行残る
使いどころ 同一行が入り込む可能性がある場合 重複なし確定の場合・大量データのETL
UNION ALL が速い理由を確認(EXPLAIN)
-- MySQL での実行計画比較
EXPLAIN
SELECT id, name FROM table_a
UNION ALL
SELECT id, name FROM table_b;
-- Extra に "Using temporary; Using filesort" が出ない → ソートなし

EXPLAIN
SELECT id, name FROM table_a
UNION
SELECT id, name FROM table_b;
-- Extra に "Using temporary; Using filesort" が出ることがある

注意:「重複がないテーブルを UNION している」のに UNION を使い続けると、大量データで性能劣化します。重複が発生しない確信があるなら必ず UNION ALL に変えましょう。

INTERSECT — 共通行だけ取り出す

INTERSECTは両方のSELECTに共通して存在する行だけを返します。いわゆる「積集合」です。

RDBMS サポート 備考
PostgreSQL ○ INTERSECT / INTERSECT ALL 標準SQL準拠
SQL Server ○ INTERSECT INTERSECT ALL は未サポート
MySQL ○ 8.0.31+ それ以前は INNER JOIN で代替
Oracle ○ INTERSECT INTERSECT ALL は未サポート
INTERSECT — 両テーブルに存在するメールアドレス
-- 顧客でもあり、かつニュースレター購読者でもある人を抽出
SELECT email FROM customers
INTERSECT
SELECT email FROM newsletter_subscribers;
MySQL 8.0.30 以前の代替(INNER JOIN)
-- INTERSECTが使えない旧バージョン向け
SELECT DISTINCT c.email
FROM   customers c
INNER JOIN newsletter_subscribers n ON c.email = n.email;

EXCEPT / MINUS — 差分行を取り出す

1つ目のSELECT結果から2つ目のSELECT結果を引き算します。「〜にあって△△にない」を表現します。

RDBMS キーワード 備考
PostgreSQL EXCEPT / EXCEPT ALL 標準SQL準拠
SQL Server EXCEPT EXCEPT ALL は未サポート
MySQL EXCEPT(8.0.31+) 旧バージョンは NOT EXISTS で代替
Oracle MINUS EXCEPT は非サポート(12c以前)
EXCEPT — 未購入の顧客を抽出
-- 顧客リストから「注文がある顧客」を差し引く
SELECT customer_id FROM customers
EXCEPT
SELECT DISTINCT customer_id FROM orders;
MySQL 8.0.30 以前の代替(NOT EXISTS)
SELECT c.customer_id
FROM   customers c
WHERE  NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

EXCEPT は順序が重要:A EXCEPT B と B EXCEPT A は結果が異なります。「A にあって B にない」がEXCEPT の意味です。

3テーブル以上のUNION

UNIONは2つ以上のSELECTを連鎖できます。実行は左から順番に行われます。

3テーブルのUNION ALL
-- 東・西・北の売上テーブルを一つに統合
SELECT sale_date, amount, '東' AS region FROM sales_east
UNION ALL
SELECT sale_date, amount, '西'            FROM sales_west
UNION ALL
SELECT sale_date, amount, '北'            FROM sales_north;

括弧によるグループ化:一部を先に評価させたい場合は括弧を使います。ただし UNION と UNION ALL を混在させると意図しない重複排除が起きることがあるので注意してください。

UNION と UNION ALL の混在に注意
-- NG: この書き方だと最後の UNION が全体に重複排除をかける
SELECT a FROM t1
UNION ALL
SELECT a FROM t2
UNION      -- ← ここで t1+t2 の結果と t3 を UNION(重複排除)
SELECT a FROM t3;

-- OK: サブクエリで先に UNION ALL をまとめてから UNION
SELECT a FROM (
    SELECT a FROM t1
    UNION ALL
    SELECT a FROM t2
) sub
UNION
SELECT a FROM t3;

ORDER BY と LIMIT の組み合わせ

UNIONとORDER BY・LIMITを同時に使う場合、書き方に注意が必要です。

全体をソートする(正しい書き方)
-- UNION 全体の結果をソート → ORDER BY は末尾に1つだけ
SELECT name, '顧客' AS type FROM customers
UNION ALL
SELECT name, 'リード'       FROM leads
ORDER BY name;
個別セグメントをソートしてから UNION(サブクエリ)
-- 各SELECT内でソートしたい場合はサブクエリ化する
-- ※ MySQL は外側のORDER BYが上書きするため、サブクエリ内ORDERは保証されない
SELECT * FROM (
    SELECT name, created_at FROM customers ORDER BY created_at DESC LIMIT 10
) top_customers
UNION ALL
SELECT * FROM (
    SELECT name, created_at FROM leads    ORDER BY created_at DESC LIMIT 10
) top_leads;
UNION 全体に LIMIT をかける
-- 全体で上位N件だけ取得
SELECT name, sale_date FROM sales_east
UNION ALL
SELECT name, sale_date FROM sales_west
ORDER BY sale_date DESC
LIMIT 100;

NULL の重複判定

通常のSQL比較では NULL = NULL は FALSE(UNKNOWN)ですが、UNION の重複排除では NULL は NULL と同じ扱いになります。

NULL の重複排除の挙動
SELECT NULL AS val
UNION
SELECT NULL;
-- 結果: 1行(NULLが1行になる)

SELECT NULL AS val
UNION ALL
SELECT NULL;
-- 結果: 2行(重複排除なし)

JOIN との根本的な違い

UNIONとJOINはどちらも「複数テーブルを扱う」操作ですが、まったく異なる方向を向いています。

比較項目 UNION JOIN
方向 行を縦に追加(行数が増える) 列を横に追加(列数が増える)
条件 列数・型が同じ 結合キーが一致
使いどころ 同じ構造の別テーブル・別セグメントを統合 テーブル間の関連付け・マスタ参照
NULL の扱い 重複排除で NULL=NULL とみなす NULL=NULL はFALSE(UNKNOWN)→ 一致しない

使い分けの基準:「同じ種類のデータが別テーブルに分かれている」→ UNION。「異なる種類のデータを結びつけたい」→ JOIN。詳しくはJOIN完全ガイドを参照してください。

実務でよく使うパターン

パターン1:複数ソースの統合レポート

オンラインとオフラインの売上など、同じ構造の複数テーブルをUNION ALLで統合して集計します。

複数ソースを統合して集計
SELECT
    region,
    SUM(amount) AS total_amount,
    COUNT(*)    AS order_count
FROM (
    SELECT region, amount FROM sales_online
    UNION ALL
    SELECT region, amount FROM sales_offline
    UNION ALL
    SELECT region, amount FROM sales_partner
) all_sales
GROUP BY region
ORDER BY total_amount DESC;

パターン2:縦展開レポート(ピボット解除)

月別・四半期別など列方向のデータを行方向に変換するとき、UNION ALLで縦に並べ替えます。

列を行に変換(アンピボット)
SELECT product_id, 'Q1' AS quarter, q1_sales AS sales FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q2',            q2_sales          FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q3',            q3_sales          FROM quarterly_sales
UNION ALL
SELECT product_id, 'Q4',            q4_sales          FROM quarterly_sales
ORDER BY product_id, quarter;

パターン3:階層・親子データの統合

カテゴリと商品など、同種だが別テーブルにある階層データを1つのリストにまとめます。

カテゴリと商品を1リストに統合
SELECT
    category_id AS id,
    category_name AS name,
    NULL         AS parent_id,
    'category'   AS item_type
FROM categories
UNION ALL
SELECT
    product_id,
    product_name,
    category_id,
    'product'
FROM products
ORDER BY item_type, name;

パターン4:全文検索の代替

全文検索インデックスがない場合、複数列をIN句やOR条件で検索した結果をUNIONで統合し、重複排除する方法があります。

複数列にまたがるキーワード検索
-- title か description のどちらかにキーワードが含まれる記事を検索
SELECT article_id, title FROM articles WHERE title       LIKE '%SQL%'
UNION
SELECT article_id, title FROM articles WHERE description LIKE '%SQL%';
-- UNION で重複(両方に含まれる記事)を自動排除

パターン5:マスタメンテナンス用の差分確認

本番とステージングのデータ差分を EXCEPT / MINUS で特定します。

本番にあってステージングにない行を特定
-- 本番DB (prod_db) にあってステージング (stg_db) にない商品
SELECT product_id, sku FROM prod_db.products
EXCEPT
SELECT product_id, sku FROM stg_db.products;

パフォーマンスのポイント

ポイント 対処法
重複排除のコスト 重複が発生しない確信があれば UNION ALL を使う
各SELECTにWHEREを書く UNIONの各SELECTで個別にインデックスが効く。外側のWHEREは効かない
大量データのETL UNION ALL + INSERT INTO … SELECT でバッチ処理
UNION後のフィルタ サブクエリ化してWHEREをかけると内側まで押し込んでくれる場合がある(RDBMS依存)
外側のWHEREはインデックスが効かないことがある
-- NG: UNION後にWHEREをかけると最適化されない可能性
SELECT id, name FROM t1
UNION ALL
SELECT id, name FROM t2
WHERE name = '山田';  -- t2 にしか効かない

-- OK: 各SELECT内にWHEREを書く
SELECT id, name FROM t1 WHERE name = '山田'
UNION ALL
SELECT id, name FROM t2 WHERE name = '山田';

よくあるエラーと対処法

列数が一致しないエラー

エラー例:The used SELECT statements have a different number of columns

列数不一致 → NULLで補う
-- NG: 列数が違う
SELECT id, name, email FROM customers
UNION ALL
SELECT id, name          FROM leads;  -- email がない → エラー

-- OK: NULL で補完
SELECT id, name, email FROM customers
UNION ALL
SELECT id, name, NULL  FROM leads;

型変換による意図しない結果

数値と文字列の混在
-- INT と VARCHAR が混在すると VARCHAR に統一される
SELECT 1   AS val
UNION ALL
SELECT '1A';
-- 結果: '1'(数値1が文字列に変換)と '1A' の2行
-- 集計関数を使うと予期しない結果になる場合がある

ORDER BY で列名が使えない(列番号を使う)

ORDER BY の列指定
-- UNIONのORDER BYでは1つ目のSELECTの列名を使う
SELECT name, '顧客' AS type FROM customers
UNION ALL
SELECT name, 'リード'       FROM leads
ORDER BY name;          -- OK: 1つ目のSELECTの列名
-- ORDER BY type;       -- OK: エイリアスが使える(RDBMS依存)
-- ORDER BY 2;          -- OK: 列番号指定(type = 2列目)

UNION を使った重複チェック

UNION は重複を排除するため、UNION ALL の件数 - UNION の件数 > 0 なら重複あり、という活用もできます。

重複の有無を確認するパターン
-- UNION ALL の件数と UNION の件数を比較
SELECT
    (SELECT COUNT(*) FROM (
        SELECT email FROM customers UNION ALL SELECT email FROM leads
    ) t1) AS union_all_count,
    (SELECT COUNT(*) FROM (
        SELECT email FROM customers UNION     SELECT email FROM leads
    ) t2) AS union_count;
-- union_all_count > union_count なら重複あり
DISTINCT との比較
-- 特定列の重複を排除するには DISTINCT も使える
-- UNION(重複排除)と SELECT DISTINCT は結果が同じになることが多い

-- UNION で重複排除
SELECT email FROM customers UNION SELECT email FROM leads;

-- サブクエリ + DISTINCT で重複排除(同義)
SELECT DISTINCT email FROM (
    SELECT email FROM customers
    UNION ALL
    SELECT email FROM leads
) combined;

RDBMS 別の対応状況まとめ

演算子 MySQL PostgreSQL SQL Server Oracle
UNION
UNION ALL
INTERSECT ○ 8.0.31+
INTERSECT ALL ○ 8.0.31+ × ×
EXCEPT ○ 8.0.31+ 21c+
EXCEPT ALL ○ 8.0.31+ × ×
MINUS × × × ○(EXCEPT の別名)

MySQL 8.0.30 以前の代替手段:

  • INTERSECT → INNER JOIN + DISTINCT
  • EXCEPT → NOT EXISTS / LEFT JOIN … WHERE IS NULL

まとめ

UNIONの集合演算を整理すると次のとおりです。

演算子 結果 重複 主な用途
UNION 両方の行を縦に連結 排除する 同種テーブルの統合・重複を避けたい場合
UNION ALL 両方の行を縦に連結 残す ETL・大量データ集計・重複なし確定の場合
INTERSECT 両方に共通する行 排除する 会員と購読者など2リストの共通部分
EXCEPT / MINUS 1つ目にあり2つ目にない行 排除する 差分チェック・未購入顧客の抽出
  • 重複が発生しないなら UNION ALL を優先(パフォーマンス向上)
  • 各SELECTの中に WHERE を書く(インデックスを活用)
  • ORDER BY は末尾に 1つだけ書いて全体をソート
  • 列を横に足したいなら UNION ではなく JOIN を使う