- SQL
SQL一時テーブルの作成|TEMPORARY TABLEとCTEの違い
一時テーブルとは、作成した接続(セッション)の中だけで使え、接続を閉じると自動で消える作業用のテーブルです。 MySQL と PostgreSQL では CREATE TEMPORARY TABLE、SQL Server では名前の先頭に # を付けた CREATE TABLE #名前 で作ります。途中の集計結果をいったん置いておき、何度も参照したりインデックスを張ったりできるのが、CTE(WITH 句)やサブクエリとの大きな違いです。
この記事では、MySQL/PostgreSQL/SQL Server/Oracle の作成構文と寿命を DB 別の表 にまとめ、一時テーブル・CTE・サブクエリ・ビューを「再利用できるか・インデックスを張れるか・いつ消えるか」で比べる早見表 を用意しました。さらに、MySQL で同じ一時テーブルを1つのクエリの中で2回参照するとエラーになる例や、別の接続からは見えないことを、MySQL 8.0.46 と PostgreSQL 16.15 で実際に動かした出力つきで紹介します。
一時テーブル・CTE・サブクエリ・ビューの早見表
| 比べる点 | 一時テーブル | CTE(WITH 句) | サブクエリ | ビュー |
|---|---|---|---|---|
| 実体(データ)を持つか | 持つ(結果を保存する) | 持たない(クエリの一部) | 持たない(クエリの一部) | 持たない(定義だけを保存) |
| 再利用できる範囲 | 同じ接続の中なら、別の SQL 文から何度でも | その SQL 文の中だけ(文の中では何度でも名前で参照できる) | 書いた場所の1か所だけ | どの接続からでも、何度でも |
| インデックスを張れるか | 張れる | 張れない | 張れない | 張れない(元のテーブルのインデックスが使われる) |
| いつ消えるか | 接続を閉じたとき(設定によりコミット時) | SQL 文が終わったとき | SQL 文が終わったとき | DROP VIEW するまで残る |
| ほかの接続から見えるか | 見えない | 見えない | 見えない | 見える |
| 元データの変更が反映されるか | 反映されない(作成時点の写し) | 毎回その時点のデータ | 毎回その時点のデータ | 毎回その時点のデータ |
| 作るのに必要な権限 | 一時テーブルの作成権限 | SELECT できれば不要 | SELECT できれば不要 | ビューの作成権限 |
| 向いている場面 | 重い中間結果を何度も使う、段階的に加工する | 1つの SQL を読みやすく分ける | その場限りの小さな条件 | よく使う SELECT に名前を付けて共有する |
迷ったときの目安は次の3行です。
- 1つの SQL 文の中で完結するなら CTE(読みやすく、後片付けも不要)。
- 複数の SQL 文で同じ中間結果を使う、またはインデックスがほしいなら一時テーブル。
- ほかの人や別の接続からも同じ定義を使いたいならビュー。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL一時テーブルの作成|TEMPORARY TABLEとCTEの違い」
DB別の一時テーブル作成構文と寿命の一覧
| DB | 作成構文 | データが見える範囲 | 消えるタイミング |
|---|---|---|---|
| MySQL | CREATE TEMPORARY TABLE t (...) / CREATE TEMPORARY TABLE t AS SELECT ... |
作成した接続だけ | 接続を閉じたとき、または DROP TEMPORARY TABLE |
| PostgreSQL | CREATE TEMP TABLE t (...) [ON COMMIT PRESERVE ROWS | DELETE ROWS | DROP] |
作成した接続だけ | 既定は接続を閉じたとき。ON COMMIT DROP ならコミット時 |
| SQL Server(ローカル) | CREATE TABLE #t (...) / SELECT ... INTO #t FROM ... |
作成した接続だけ | 接続を切断したとき |
| SQL Server(グローバル) | CREATE TABLE ##t (...) |
すべての接続 | 参照しているすべての接続が切断したとき |
| SQL Server(テーブル変数) | DECLARE @t TABLE (...) |
宣言したバッチ・プロシージャの中 | バッチ・プロシージャが終わったとき |
| Oracle(グローバル一時表) | CREATE GLOBAL TEMPORARY TABLE t (...) ON COMMIT DELETE ROWS | PRESERVE ROWS |
定義は全接続で共有、データは接続ごと | 定義は残り続ける。データはコミット時(DELETE ROWS)か接続終了時(PRESERVE ROWS) |
| Oracle(プライベート一時表) | CREATE PRIVATE TEMPORARY TABLE ora$ptt_t (...) ON COMMIT DROP DEFINITION | PRESERVE DEFINITION |
作成した接続だけ | コミット時(DROP DEFINITION)か接続終了時(PRESERVE DEFINITION) |
- MySQL と PostgreSQL の行は今回実行して確かめた内容です。
- SQL Server の
#・##の見える範囲と消えるタイミングは、Microsoft Learn の「テーブル」のページを開いて確認しました(一時テーブルは tempdb に格納されます)。SQL Server のコード自体は手元に実行環境がないため 未実行 です。 - Oracle は実行環境がなく、公式ページも今回は開けていないため 未実行・一般に知られた仕様 として載せています。プライベート一時表は Oracle Database 18c 以降の機能で、名前の先頭に
ORA$PTT_が必要とされています。
MySQLで一時テーブルを作成する(CREATE TEMPORARY TABLE)
以降の例では、次の注文テーブルを使います(MySQL・PostgreSQL 共通)。
CREATE TABLE orders (
order_id INT PRIMARY KEY,
member_id INT,
amount INT,
ordered_on DATE
);
INSERT INTO orders VALUES
(1,101,3000,'2026-09-01'),(2,102,1200,'2026-09-03'),(3,101,4500,'2026-09-10'),
(4,103,800,'2026-09-15'),(5,102,2600,'2026-09-20'),(6,101,1500,'2026-09-28');
SELECTの結果から作る
一番よく使うのは、集計結果をそのまま一時テーブルにする書き方です。
CREATE TEMPORARY TABLE tmp_member_total AS
SELECT member_id, SUM(amount) AS total, COUNT(*) AS cnt
FROM orders
GROUP BY member_id;
SELECT * FROM tmp_member_total;
MySQL 8.0.46 の実行結果です。
+-----------+-------+-----+
| member_id | total | cnt |
+-----------+-------+-----+
| 101 | 9000 | 3 |
| 102 | 3800 | 2 |
| 103 | 800 | 1 |
+-----------+-------+-----+
列を定義して作る・インデックスを張る
列の型やインデックスを自分で決めたいときは、通常の CREATE TABLE と同じ形で書きます。
CREATE TEMPORARY TABLE tmp_def (
id INT PRIMARY KEY,
name VARCHAR(20),
INDEX idx_name (name)
);
AS SELECT で作った一時テーブルにも、あとから主キーやインデックスを足せます。
ALTER TABLE tmp_member_total
ADD PRIMARY KEY (member_id),
ADD INDEX idx_total (total);
SHOW CREATE TABLE tmp_member_total;
CREATE TEMPORARY TABLE `tmp_member_total` (
`member_id` int NOT NULL,
`total` decimal(32,0) DEFAULT NULL,
`cnt` bigint NOT NULL DEFAULT '0',
PRIMARY KEY (`member_id`),
KEY `idx_total` (`total`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
SUM(amount) の列が decimal(32,0)、COUNT(*) の列が bigint と、AS SELECT では型が自動で決まります。 型を自分で決めたいときは、列定義つきで作ってから INSERT ... SELECT で入れます。
SHOW TABLESには出てこない
SHOW TABLES;
+----------------+
| Tables_in_t180 |
+----------------+
| orders |
+----------------+
一時テーブルは SHOW TABLES にも information_schema.TABLES にも表示されません(どちらも orders だけが返りました)。自分の接続にある InnoDB の一時テーブルは、次のクエリで件数や列数を確認できます。名前は内部名(#sql…)で表示されました(下の出力は4行のうち2行の抜粋です)。
SELECT NAME, N_COLS FROM information_schema.INNODB_TEMP_TABLE_INFO;
+-------------+--------+
| NAME | N_COLS |
+-------------+--------+
| #sqlaf8_b_f | 4 |
| #sqlaf8_b_b | 5 |
+-------------+--------+
定義を見たいときは SHOW CREATE TABLE 一時テーブル名 が手軽です。
別の接続からは見えない
一時テーブルを作った接続を開いたまま、別の接続から同じ名前を SELECT してみます。
-- 別の接続で実行
SELECT * FROM tmp_member_total;
ERROR 1146 (42S02) at line 1: Table 't180.tmp_member_total' doesn't exist
別の接続からは「テーブルが存在しない」というエラーになりました。 そのため、複数の人や処理が同時に同じ名前の一時テーブルを作っても、互いにぶつかりません。
同じ一時テーブルを1つのクエリで2回参照するとエラーになる
MySQL の一時テーブルで一番つまずきやすい点です。自己結合してみます。
SELECT a.member_id, b.total
FROM tmp_member_total a
JOIN tmp_member_total b ON a.total < b.total;
ERROR 1137 (HY000) at line 1: Can't reopen table: 'a'
サブクエリや UNION で2回使っても同じエラーになりました(別名を付けていない場合、メッセージにはテーブル名が出ます)。
SELECT * FROM tmp_member_total
WHERE total = (SELECT MAX(total) FROM tmp_member_total);
ERROR 1137 (HY000) at line 9: Can't reopen table: 'tmp_member_total'
SELECT member_id FROM tmp_member_total
UNION ALL
SELECT member_id FROM tmp_member_total;
ERROR 1137 (HY000) at line 10: Can't reopen table: 'tmp_member_total'
MySQL 8.0 リファレンスマニュアルの「TEMPORARY Table Problems」のページにも、同じクエリの中で一時テーブルを2回以上参照できない という制限と、回避策として CTE が挙げられています(今回このページを開いて確認しました)。
直し方は2つあります。
直し方1:同じ文の中で2回使うなら CTE にする。
WITH t AS (
SELECT member_id, SUM(amount) AS total FROM orders GROUP BY member_id
)
SELECT a.member_id, a.total, b.total AS other_total
FROM t a JOIN t b ON a.total < b.total
ORDER BY 1, 3;
+-----------+-------+-------------+
| member_id | total | other_total |
+-----------+-------+-------------+
| 102 | 3800 | 9000 |
| 103 | 800 | 3800 |
| 103 | 800 | 9000 |
+-----------+-------+-------------+
直し方2:一時テーブルをもう1つ複製して、別の名前で結合する。
CREATE TEMPORARY TABLE tmp_copy AS SELECT * FROM tmp_member_total;
SELECT a.member_id, a.total, b.total AS other_total
FROM tmp_member_total a JOIN tmp_copy b ON a.total < b.total
ORDER BY 1, 3;
こちらも上と同じ3行が返りました。なお、PostgreSQL 16.15 では一時テーブルの自己結合はそのまま実行でき、同じ3行が返っています。この制限は MySQL 特有のもの です。
同じ名前の通常テーブルは一時的に隠れる
CREATE TEMPORARY TABLE orders (note VARCHAR(20));
SELECT * FROM orders; -- 一時テーブル側が見える
Empty set (0.00 sec)
DROP TEMPORARY TABLE orders; -- 一時テーブルだけを削除
SELECT COUNT(*) FROM orders; -- 元のテーブルが再び見える
+----------+
| COUNT(*) |
+----------+
| 6 |
+----------+
通常のテーブルと同じ名前で一時テーブルを作ると、その接続では一時テーブルが優先されます。 取り違えを防ぐため、一時テーブルには tmp_ などの接頭辞を付けておくのがおすすめです。
削除する(DROP TEMPORARY TABLE)
DROP TEMPORARY TABLE IF EXISTS tmp_copy, tmp_in_tx;
TEMPORARY を付けておくと、一時テーブルだけが削除の対象 になります。通常テーブルの orders に対して DROP TEMPORARY TABLE orders を実行したところ、次のエラーになり、通常テーブルはそのまま残りました。
ERROR 1051 (42S02) at line 35: Unknown table 't180.orders'
同じ名前で作り直すときの挙動も確認しました。
| 実行した文 | 結果 |
|---|---|
CREATE TEMPORARY TABLE tmp_def (id INT);(2回目) |
ERROR 1050 (42S01): Table 'tmp_def' already exists |
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_def (id INT); |
エラーにならず、Note 1050 Table 'tmp_def' already exists が出る(中身は元のまま) |
DROP TEMPORARY TABLE not_exist_tmp; |
ERROR 1051 (42S02): Unknown table 't180.not_exist_tmp' |
何度実行しても同じ状態から始めたいスクリプトでは、先頭に DROP TEMPORARY TABLE IF EXISTS を置いてから作成する と安定します。
トランザクションとの関係
START TRANSACTION;
INSERT INTO tmp_def VALUES (1, 'a');
CREATE TEMPORARY TABLE tmp_in_tx (x INT);
ROLLBACK;
SELECT COUNT(*) AS rows_after_rollback FROM tmp_def; -- 0
SELECT COUNT(*) AS tmp_in_tx_exists FROM tmp_in_tx; -- 0(テーブルは残っている)
MySQL 8.0.46(InnoDB)では、一時テーブルへの INSERT はロールバックで取り消され、トランザクション中に作った一時テーブルそのものはロールバック後も残りました。 PostgreSQL とは挙動が違うので、次の章と比べてみてください。
PostgreSQLで一時テーブルを作成する(ON COMMITの3種類)
PostgreSQL では TEMPORARY を TEMP と短く書けます。
CREATE TEMP TABLE tmp_member_total AS
SELECT member_id, SUM(amount) AS total, COUNT(*) AS cnt
FROM orders GROUP BY member_id;
一時テーブルは pg_temp_数字 という専用のスキーマに作られます。
SELECT schemaname, tablename FROM pg_tables
WHERE tablename IN ('orders', 'tmp_member_total');
schemaname | tablename
------------+------------------
public | orders
pg_temp_3 | tmp_member_total
別の接続から SELECT すると、MySQL と同じく存在しない扱いになりました。
ERROR: relation "tmp_member_total" does not exist
LINE 1: SELECT * FROM tmp_member_total
^
ON COMMITでコミット時の動きを選ぶ
| 指定 | コミットしたとき | 向いている場面 |
|---|---|---|
ON COMMIT PRESERVE ROWS(既定) |
テーブルも行も残る | 接続の間ずっと使う作業テーブル |
ON COMMIT DELETE ROWS |
テーブルは残り、行だけ空になる | トランザクションごとに空の状態から使い回す |
ON COMMIT DROP |
テーブルごと消える | 1つのトランザクションの中だけで使う |
3種類を同じトランザクションで作って比べました。
BEGIN;
CREATE TEMP TABLE tmp_drop (id INT) ON COMMIT DROP;
CREATE TEMP TABLE tmp_del (id INT) ON COMMIT DELETE ROWS;
CREATE TEMP TABLE tmp_keep (id INT) ON COMMIT PRESERVE ROWS;
INSERT INTO tmp_drop VALUES (1);
INSERT INTO tmp_del VALUES (1);
INSERT INTO tmp_keep VALUES (1);
COMMIT;
SELECT COUNT(*) AS del_rows FROM tmp_del;
SELECT COUNT(*) AS keep_rows FROM tmp_keep;
SELECT COUNT(*) FROM tmp_drop;
PostgreSQL 16.15 の実行結果です。
del_rows
----------
0
keep_rows
-----------
1
ERROR: relation "tmp_drop" does not exist
LINE 1: SELECT COUNT(*) FROM tmp_drop;
^
ON COMMIT DROPはトランザクションの中で使う
psql の既定(自動コミット)のまま ON COMMIT DROP 付きで作ると、その文が終わった時点でコミットされるため、次の文ではもう存在しません。
CREATE TEMP TABLE tmp_a (id INT) ON COMMIT DROP;
SELECT * FROM tmp_a;
CREATE TABLE
ERROR: relation "tmp_a" does not exist
LINE 1: SELECT * FROM tmp_a;
^
ON COMMIT DROP は BEGIN; 〜 COMMIT; の間で作って使う、と覚えておけば迷いません。
ROLLBACKすると一時テーブルごとなくなる
BEGIN;
CREATE TEMP TABLE tmp_rb (id INT);
ROLLBACK;
SELECT * FROM tmp_rb;
ERROR: relation "tmp_rb" does not exist
PostgreSQL では CREATE TABLE もトランザクションの対象なので、ロールバックすると作成自体が取り消されます(MySQL では残りました)。
インデックス・ANALYZE・LIKE
CREATE INDEX idx_tmp_total ON tmp_member_total (total);
ANALYZE tmp_member_total;
一時テーブルにも通常のテーブルと同じようにインデックスを張れます。一時テーブルは自動バキューム(autovacuum)の対象外として知られているため、データを入れたあとに自分で ANALYZE を実行しておくと、統計情報に基づいた実行計画が選ばれやすくなります。
既存テーブルと同じ列・主キーを持つ空の一時テーブルは LIKE で作れます。
CREATE TEMP TABLE tmp_like (LIKE public.orders INCLUDING ALL);
\d tmp_like で確認すると、列4つと "tmp_like_pkey" PRIMARY KEY, btree (order_id) が引き継がれていました。取り込み前のデータを一度受け止める作業テーブルに便利です。
同じ名前のテーブルと既存チェック
| 実行した文 | PostgreSQL 16.15 の結果 |
|---|---|
CREATE TEMP TABLE orders (note TEXT); のあと SELECT * FROM orders; |
一時テーブル側(0 行)が返る |
SELECT COUNT(*) FROM public.orders; |
6(スキーマ名を付けると元のテーブルを指定できる) |
DROP TABLE pg_temp.orders; |
一時テーブルだけを削除できる |
CREATE TEMP TABLE tmp_keep (id INT);(2回目) |
ERROR: relation "tmp_keep" already exists |
CREATE TEMP TABLE IF NOT EXISTS tmp_keep (id INT); |
NOTICE: relation "tmp_keep" already exists, skipping |
PostgreSQL には DROP TEMPORARY TABLE という書き方はなく、DROP TABLE を使います。一時テーブルだけを確実に指定したいときは pg_temp.テーブル名 と書きます。
SQL ServerとOracleの一時テーブル(未実行)
この章のコードは手元で実行していません。構文の全体像をつかむための参考として載せています。
SQL Server:#・##・テーブル変数
-- ローカル一時テーブル(作成した接続だけで見える)
CREATE TABLE #member_total (
member_id INT PRIMARY KEY,
total INT
);
INSERT INTO #member_total
SELECT member_id, SUM(amount) FROM orders GROUP BY member_id;
-- SELECT の結果からそのまま作る
SELECT member_id, SUM(amount) AS total
INTO #member_total2
FROM orders GROUP BY member_id;
-- グローバル一時テーブル(すべての接続から見える)
CREATE TABLE ##shared_total (member_id INT, total INT);
-- テーブル変数
DECLARE @t TABLE (member_id INT PRIMARY KEY, total INT);
INSERT INTO @t SELECT member_id, SUM(amount) FROM orders GROUP BY member_id;
-- 削除(SQL Server 2016 以降)
DROP TABLE IF EXISTS #member_total;
| 種類 | 名前の付け方 | 見える範囲 | 消えるタイミング | 補足 |
|---|---|---|---|---|
| ローカル一時テーブル | #名前 |
作成した接続 | 接続を切断したとき | ストアドプロシージャの中で作ると、プロシージャ終了時に消えることが知られている |
| グローバル一時テーブル | ##名前 |
すべての接続 | 参照している接続がすべて切断したとき | 名前がぶつかりやすいので用途を絞る |
| テーブル変数 | @名前 |
宣言したバッチ・プロシージャ | バッチ・プロシージャ終了時 | 少量の行向け。あとから CREATE INDEX はできず、宣言時の主キー・UNIQUE などで張る |
SQL Server の一時テーブルは tempdb に置かれます。MySQL や PostgreSQL と違い TEMPORARY というキーワードは使わず、名前の先頭の # の数で種類が決まる のが特徴です。
Oracle:グローバル一時表とプライベート一時表
-- グローバル一時表:定義は一度作れば残り、データだけが接続ごとに分かれる
CREATE GLOBAL TEMPORARY TABLE gtt_member_total (
member_id NUMBER PRIMARY KEY,
total NUMBER
) ON COMMIT PRESERVE ROWS; -- 省略時は ON COMMIT DELETE ROWS
-- プライベート一時表(18c 以降):定義ごと接続の中だけに存在する
CREATE PRIVATE TEMPORARY TABLE ora$ptt_member_total (
member_id NUMBER,
total NUMBER
) ON COMMIT PRESERVE DEFINITION; -- 省略時は ON COMMIT DROP DEFINITION
| 種類 | 定義(列の情報) | データ | 向いている場面 |
|---|---|---|---|
| グローバル一時表 | 全接続で共有され、DROP TABLE するまで残る |
接続ごとに別。コミット時または接続終了時に消える | 決まった形の作業テーブルを、アプリケーションから繰り返し使う |
| プライベート一時表 | 作成した接続だけに存在する | 同上 | その場で形を決めて使い捨てる(MySQL・PostgreSQL の一時テーブルに近い) |
Oracle のグローバル一時表は「テーブルの定義は消えない」 点が、ほかの DB の一時テーブルと大きく違います。あらかじめ管理者が作成しておき、利用側は INSERT と SELECT だけを行う、という使い方が一般的です。
CTE・サブクエリ・ビューとの違いを実行結果で確かめる
早見表の内容を、PostgreSQL 16.15 で実際に確かめます。
CTEはその文が終わると消える
WITH t AS (SELECT member_id, SUM(amount) AS total FROM orders GROUP BY member_id)
SELECT * FROM t WHERE member_id = 101;
SELECT * FROM t; -- 次の文では t は使えない
CTE は WITH を書いた SQL 文の中だけで有効です。次の文で同じ集計を使いたいときは、もう一度同じ WITH を書くか、一時テーブルにします。
CTEは「毎回計算し直す」ことも「1回だけ計算する」こともある
実行計画(EXPLAIN)で、CTE がどう扱われるかを見ます。
EXPLAIN (COSTS OFF)
WITH t AS (SELECT member_id, SUM(amount) AS total FROM orders GROUP BY member_id)
SELECT * FROM t WHERE member_id = 101;
QUERY PLAN
-----------------------------------
GroupAggregate
-> Seq Scan on orders
Filter: (member_id = 101)
CTE を1回だけ参照した場合は、外側の条件(member_id = 101)が CTE の中に入り込み、ふつうのサブクエリと同じ計画になりました。 一方、同じ CTE を2回参照すると次のようになります。
EXPLAIN (COSTS OFF)
WITH t AS (SELECT member_id, SUM(amount) AS total FROM orders GROUP BY member_id)
SELECT a.member_id FROM t a JOIN t b ON a.total < b.total;
QUERY PLAN
---------------------------------------
Nested Loop
Join Filter: (a.total < b.total)
CTE t
-> HashAggregate
Group Key: orders.member_id
-> Seq Scan on orders
-> CTE Scan on t a
-> CTE Scan on t b
2回参照した場合は、集計を1回だけ行い、その結果(CTE Scan)を2か所で読んでいます。 WITH t AS MATERIALIZED (...) と書けば、1回しか参照しない場合でも結果を先に確定させる計画(CTE Scan)になることも確認しました。CTE の書き方や再帰クエリの詳しい使い方は範囲が広いので、ここでは一時テーブルとの違いに絞っています。
ビューにはインデックスを張れない
CREATE VIEW v_member_total AS
SELECT member_id, SUM(amount) AS total FROM orders GROUP BY member_id;
CREATE INDEX ON v_member_total (total);
ERROR: cannot create index on relation "v_member_total"
DETAIL: This operation is not supported for views.
ビューは SELECT 文に名前を付けたもので、データを持ちません。検索のたびに元のテーブルを読むので、速さは元のテーブルのインデックスしだいです。結果を保存してインデックスを張りたいときは、一時テーブル(接続の中だけ)か、マテリアライズドビュー(全体で共有)が候補になります。
性能の違い:100万行の集計結果を繰り返し使う
100万行のテーブルを用意し、「会員ごとの合計」を自己結合で2回使う処理を、CTE と一時テーブルで比べました。
CREATE TABLE big_orders AS
SELECT g AS order_id, (g % 50000) + 1 AS member_id, (g * 7 % 9000) + 100 AS amount
FROM generate_series(1, 1000000) g;
ANALYZE big_orders;
-- CTE 版
WITH t AS (SELECT member_id, SUM(amount) AS total FROM big_orders GROUP BY member_id)
SELECT COUNT(*) FROM t a JOIN t b ON a.member_id = b.member_id + 1 WHERE a.total > b.total;
-- 一時テーブル版
CREATE TEMP TABLE tmp_big AS
SELECT member_id, SUM(amount) AS total FROM big_orders GROUP BY member_id;
CREATE INDEX ON tmp_big (member_id);
ANALYZE tmp_big;
SELECT COUNT(*) FROM tmp_big a JOIN tmp_big b ON a.member_id = b.member_id + 1 WHERE a.total > b.total;
psql の \timing で測った結果です(PostgreSQL 16.15、同じ結果 49649 件。1〜2回の計測値で、環境によって変わります)。
| 処理 | かかった時間 |
|---|---|
| CTE 版のクエリ(1回目/2回目) | 295.8 ms/210.4 ms |
| 一時テーブルの作成(集計して保存) | 203.1 ms |
| 一時テーブルへのインデックス作成+ANALYZE | 16.8 ms+30.0 ms |
| 一時テーブル版のクエリ(1回目/2回目) | 17.8 ms/14.6 ms |
| 一時テーブルから会員1人分を検索(インデックス使用) | 0.4 ms |
| サブクエリで毎回集計して会員1人分を検索 | 64.3 ms |
読み取れることは次のとおりです。
- 1回しか使わないなら CTE が手軽。一時テーブルは「作成+インデックス+ANALYZE」で約250 ms かかるので、1回だけなら CTE(約210〜296 ms)と大きな差はありません。
- 同じ中間結果を何度も使うなら一時テーブルが有利。2回目以降のクエリは約15 ms で、CTE 版は実行のたびに100万行の集計をやり直します。
- インデックスで1件を引くような使い方は一時テーブルの得意分野(0.4 ms と 64.3 ms)。
一時テーブルを使うか決める判断表
flowchart TD
A[中間結果を使いたい] --> B{1つのSQL文の中だけで使う?}
B -- はい --> C{その場で1回だけ?}
C -- はい --> D[サブクエリ または CTE]
C -- 何度も参照する --> E[CTE]
B -- いいえ 複数の文で使う --> F{ほかの接続とも共有する?}
F -- いいえ --> G[一時テーブル]
F -- はい --> H[ビュー / マテリアライズドビュー]
| 状況 | おすすめ | 理由 |
|---|---|---|
| 長い SELECT を読みやすく分けたい | CTE | 後片付けが不要で、上から順に読める |
| 重い集計の結果を、続く複数の SQL で使う | 一時テーブル | 集計は1回で済む |
| 中間結果を条件で絞ったり結合したりする回数が多い | 一時テーブル+インデックス | インデックスと統計情報を使える |
| 大量データを段階的に加工する(取り込み→整形→本テーブルへ) | 一時テーブル | 途中の状態を SELECT で確かめながら進められる |
| 参照専用の接続・権限しかない | CTE | 一時テーブルの作成権限が要らない |
| MySQL で自己結合したい | CTE(または一時テーブルを複製) | 一時テーブルは同じクエリで2回参照できない |
| チーム全員が同じ定義を使う | ビュー | 定義がデータベースに残る |
つまずきやすいポイントと対処
| 現象・エラー | DB | 原因 | 対処 |
|---|---|---|---|
ERROR 1137: Can't reopen table |
MySQL | 同じクエリで一時テーブルを2回参照した | CTE にする、または複製して別名で結合する |
ERROR 1146: Table '…' doesn't exist |
MySQL | 別の接続から参照した/接続が切れて消えた | 同じ接続で作成から参照まで行う |
relation "…" does not exist |
PostgreSQL | 別の接続、ON COMMIT DROP 後、ROLLBACK 後 |
BEGIN 〜 COMMIT の中で使う、または ON COMMIT 指定を見直す |
ERROR 1050: Table '…' already exists / relation "…" already exists |
MySQL / PostgreSQL | 同じ接続で2回作成した | 先に DROP ... IF EXISTS を実行する |
| 元のテーブルが空に見える | MySQL / PostgreSQL | 同じ名前の一時テーブルが優先されている | 一時テーブルを削除する。名前に tmp_ を付ける |
| 前回の一時テーブルが残っている | 共通 | 接続プールで同じ接続が使い回されている | 処理の最後に明示的に DROP する |
| 一時テーブルへの検索が思ったより遅い | PostgreSQL | 統計情報がまだない | データ投入後に ANALYZE を実行する |
| 元テーブルを更新したのに結果が変わらない | 共通 | 一時テーブルは作成時点の写し | 作り直す、または CTE・ビューを使う |
接続プール(アプリケーションが接続を使い回す仕組み)を使っている場合、「接続を閉じたら消える」はアプリケーションの処理が終わったときとは限りません。 処理の最後に DROP TEMPORARY TABLE IF EXISTS(PostgreSQL は DROP TABLE IF EXISTS)を入れるか、PostgreSQL ならトランザクション内で ON COMMIT DROP を使うと、次の処理に持ち越されません。
よくある質問
Q. 一時テーブルと CTE はどちらが速いですか? A. 使う回数によります。今回の計測では、1回だけ使うなら差は小さく、同じ中間結果を2回以上使う場合は一時テーブル(約15 ms)が CTE(約210〜296 ms)より短い時間で済みました。まず CTE で書き、同じ集計を何度も繰り返していると感じたら一時テーブルを検討する、という順番がおすすめです。
Q. 一時テーブルは自分で削除しなくてもよいですか? A. 接続を閉じれば自動で消えます。ただし、接続プールを使うアプリケーションや長時間つないだままの接続では残り続けるので、使い終わったら明示的に DROP しておくと安心です。
Q. ほかの人が同じ名前の一時テーブルを作っても大丈夫ですか?
A. MySQL・PostgreSQL の一時テーブルと SQL Server の # 付きテーブルは接続ごとに別物なので、同じ名前でもぶつかりません。SQL Server の ##(グローバル一時テーブル)と Oracle のグローバル一時表の定義は共有されるため、名前の重複に注意します。
Q. 一時テーブルにも主キーやインデックスを付けられますか?
A. 付けられます。MySQL では ALTER TABLE ... ADD PRIMARY KEY・ADD INDEX、PostgreSQL では CREATE INDEX が通常のテーブルと同じように使えることを確認しました。
まとめ
- 一時テーブルは「作成した接続だけで見え、接続を閉じると消える」作業用テーブル。別の接続からは存在しない扱いになる。
- 作成構文は DB で違う:MySQL・PostgreSQL は
CREATE TEMPORARY TABLE、SQL Server は#名前、Oracle はグローバル一時表とプライベート一時表。 - PostgreSQL は
ON COMMITで寿命を選べる(PRESERVE ROWS/DELETE ROWS/DROP)。ON COMMIT DROPはトランザクションの中で使う。 - MySQL の一時テーブルは同じクエリで2回参照すると
ERROR 1137: Can't reopen table。CTE にするか、複製して結合する。 - CTE はその SQL 文だけで有効、ビューは定義が残って共有できる、一時テーブルはデータを持ちインデックスを張れる。
- 同じ中間結果を何度も使うなら一時テーブル、1つの文で完結するなら CTE が目安。
- 後片付けは
DROP ... IF EXISTSを習慣に。接続プールでは接続が使い回される点に注意する。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL一時テーブルの作成|TEMPORARY TABLEとCTEの違い」
株式会社RJCでは、一時テーブルやCTEの使い分けを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。