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点のみ。テーブルが同じである必要はありません。
-- 顧客テーブルとリードテーブルを縦に統合 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やリテラルで補う 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 |
-- 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 は未サポート |
-- 顧客でもあり、かつニュースレター購読者でもある人を抽出 SELECT email FROM customers INTERSECT SELECT email FROM newsletter_subscribers;
-- 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以前) |
-- 顧客リストから「注文がある顧客」を差し引く SELECT customer_id FROM customers EXCEPT SELECT DISTINCT customer_id FROM orders;
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を連鎖できます。実行は左から順番に行われます。
-- 東・西・北の売上テーブルを一つに統合 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 を混在させると意図しない重複排除が起きることがあるので注意してください。
-- 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;
-- 各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;
-- 全体で上位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 と同じ扱いになります。
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つのリストにまとめます。
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依存) |
-- 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
-- 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 で列名が使えない(列番号を使う)
-- 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 も使える
-- 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 を使う
