SQL Server で INSERT INTO SELECT と SELECT INTO を使い分ける3パターン

SQL Server で INSERT INTO SELECT と SELECT INTO を使い分ける3パターン

みなさんこんにちは!ヒロポンです!

旧テーブルから別のテーブルへデータを移したい。退避したい。

SQL Server でその場面に立つと、毎回ちょっと手が止まりませんか??

INSERT INTO ... SELECT なのか、SELECT ... INTO なのか。見た目が似てるくせに、中身は別物なんですよね。

俺も業務系の保守をやってた頃、月次の締めデータを年度別テーブルに退避するスクリプトで、ここの選択をミスって本番が数分固まったことがある。地味に冷や汗かくやつ。

この記事は、その「どっちで入れる?」を業務SEが迷わず選べるように、3パターンを結論先出しで整理します。全部コピペで動く T-SQL 付きです。

目次

結論: 既存テーブルがあるなら INSERT INTO … SELECT、器ごと作るなら SELECT INTO

先に答えを置きます。

SQL Server で SELECT 結果を別テーブルに入れる時は、入れ先テーブルが「もうあるか / ないか」で最初に分かれます。

  • 入れ先テーブルがもうあるINSERT INTO 既存テーブル (列…) SELECT …
  • 入れ先テーブルを新しく器ごと作りたいSELECT … INTO 新テーブル FROM …
  • いったん退避して加工してから戻したい → 一時テーブル (#temp) 経由

この3つ、ロックの範囲も、制約の引き継ぎ方も、巻き戻しの単位も違います。

だから「どれでも一緒でしょ」で選ぶと、後で痛い目を見る。順番に見ていきます。

なぜ3パターンに分かれるのか

理由はシンプルです。INSERT INTO ... SELECT は「既存の器にデータを流し込むだけ」、SELECT ... INTO は「器を新規に作りながら流し込む」、一時テーブル経由は「tempdb に一回逃がす」。やってることが根本から違う。

器を作るか作らないかで、引き継がれる制約 (PRIMARY KEY や INDEX、既定値) が変わる。逃がす先が tempdb かどうかで、ロールバックの軽さが変わる。

つまり選択軸は「既存テーブルの有無」「制約を残したいか」「ロック・巻き戻しをどこまで気にするか」。この3つだけ押さえれば迷いません。

最短対処: コピペで動く3つの書き方

まず準備。移行元 Orders_2024 と移行先 Orders_Archive を用意します。以降の3パターンはこの状態から続けて流す前提です。

-- 準備: 移行元と移行先テーブルを用意してサンプルデータを入れる
CREATE TABLE dbo.Orders_2024 (
    OrderId   INT           NOT NULL PRIMARY KEY,
    CustName  NVARCHAR(50)  NOT NULL,
    Amount    DECIMAL(10,2) NOT NULL,
    OrderDate DATE          NOT NULL
);

CREATE TABLE dbo.Orders_Archive (
    OrderId   INT           NOT NULL PRIMARY KEY,
    CustName  NVARCHAR(50)  NOT NULL,
    Amount    DECIMAL(10,2) NOT NULL,
    OrderDate DATE          NOT NULL
);

INSERT INTO dbo.Orders_2024 (OrderId, CustName, Amount, OrderDate) VALUES
    (1, N'アルファ商事', 12000.00, '2024-01-10'),
    (2, N'ベータ物産',   8500.50,  '2024-02-03'),
    (3, N'ガンマ運輸',   30000.00, '2024-01-25');

パターン1: INSERT INTO 既存テーブル (列指定) SELECT

入れ先がもうある時はこれ。列はきっちり明示します。

-- パターン1: 既存テーブルに、列を指定して SELECT 結果を入れる
INSERT INTO dbo.Orders_Archive (OrderId, CustName, Amount, OrderDate)
SELECT OrderId, CustName, Amount, OrderDate
FROM dbo.Orders_2024
WHERE OrderDate < '2024-02-01';

-- 入った行を確認
SELECT * FROM dbo.Orders_Archive;

実行結果:

パターン1 INSERT INTO SELECT の実行結果 — WHERE で絞った2024年1月分の OrderId 1,3 だけが移った証跡

実行すると、WHERE で絞った2024年1月分の2行だけがいい感じに移ります。

ポイントは (OrderId, CustName, Amount, OrderDate) の列リスト。これを省くと、テーブル定義の列順に依存して、列がズレた時に黙って変な値が入る。面倒でも書いておくのが正解です。

既存テーブルの PRIMARY KEY や制約はそのまま効くので、重複や NULL は弾かれます。これが地味に安心。

パターン2: SELECT … INTO 新テーブル FROM …

入れ先テーブルがまだ無くて、「とりあえず今の状態をまるごと別名でスナップショットしたい」時はこっち。

-- パターン2: SELECT INTO で新テーブルを自動生成しながら入れる
SELECT OrderId, CustName, Amount, OrderDate
INTO dbo.Orders_Snapshot   -- ← この瞬間に新テーブルが作られる
FROM dbo.Orders_2024;

SELECT * FROM dbo.Orders_Snapshot;

INTO dbo.Orders_Snapshot と書いた瞬間に、テーブルが自動で作られます。CREATE TABLE を事前に書かなくていいので、めっちゃ速い!!

ただしコピーされるのは列の型と NULL 許容だけ。え、PRIMARY KEY は??ってなりますよね。INDEX も既定値も IDENTITY も付いてきません。ここが後で効いてくる罠なので、後半で詳しく書きます。

パターン3: 一時テーブルに退避してから加工して戻す

「一回どこかに逃がして、加工してから本テーブルへ戻したい」。移行系でいちばん多いのがこれです。

-- パターン3: 一時テーブル(#stage)に退避→加工→本テーブルへ戻す
SELECT OrderId, CustName, Amount, OrderDate
INTO #stage                -- tempdb 上に一時テーブルを自動生成
FROM dbo.Orders_2024;

UPDATE #stage SET Amount = Amount * 1.10;   -- 退避先で消費税ぶんを加工

INSERT INTO dbo.Orders_Archive (OrderId, CustName, Amount, OrderDate)
SELECT OrderId, CustName, Amount, OrderDate
FROM #stage
WHERE OrderId NOT IN (SELECT OrderId FROM dbo.Orders_Archive);  -- 既に入ってる分は除く

#stage は tempdb に作られる一時テーブルで、セッションが終われば勝手に消えます。本テーブルを直接いじらずに加工できるので、途中で失敗しても本番データは無傷。

こんな感じで一回ワンクッション挟むのが、移行系だといちばん安全なんですよね。

ここまでの3パターン、表で整理するとこんな感じになります。

INSERT INTO SELECT / SELECT INTO / 一時テーブル経由 を「既存テーブルなしでOK・制約とINDEXが残る・ロックが狭い・ロールバックが軽い」の4観点で比較した表

判断フローにするとこうです。入れ先テーブルの有無から下りていけば、迷わず選べます。

SELECT結果をどこに入れるかの判断フロー。入れ先テーブルがあればINSERT INTO SELECT、なければ制約を引き継ぎたいかでCREATE+INSERTかSELECT INTOに分岐、加工して戻すなら一時テーブル経由

ハマりポイント: 知らないと一晩飛ぶやつ

ここからが本題かもしれません。

3パターンを動かすだけなら簡単なんですが、知らずに踏むと夜中に呼び出される罠が3つあります。

① SELECT INTO はインデックスも既定値も IDENTITY も引き継がない

いちばん多い勘違いがこれ。SELECT ... INTO で作った新テーブルは、元テーブルとは見た目だけそっくりな別物です。

引き継がれるのは列の型と NULL 許容だけ。PRIMARY KEY、INDEX、既定値 (DEFAULT)、IDENTITY、計算列、外部キー、これらは全部消えます。

俺はこれで一回やられました。

スナップショットを SELECT INTO で作って、そのまま「移行先テーブルできた!」と思ってたんですよ。よっしゃ完了、って。ところが PRIMARY KEY が無いせいで後続のバッチが重複行を弾けず、二重計上が発生。気づくまで小一時間かかりました。まじでやらかした。

制約も移したいなら、横着せずに CREATE TABLE で器をちゃんと定義してから、パターン1の INSERT INTO ... SELECT で流す。これが結局いちばん事故りません。

② 大量行を一発で入れるとロックがテーブルごと昇格して固まる

数百万行を INSERT INTO ... SELECT で一気に流すと、SQL Server が行ロックを抱えきれなくなって、テーブル全体のロック (ロックエスカレーション) に昇格することがあります。

こうなると、そのテーブルを触ろうとした他のバッチや画面が一斉に待たされる。業務時間帯だと普通に事故です。

この閾値、書籍にちゃんと書いてあります。Pro SQL Server Internals (Dmitri Korotkevitch, p.459) のロックエスカレーションの章です。

大きなバッチの行を1つの INSERT 文で入れる処理は、排他ロックをテーブル単位へ昇格させ、ほかのセッションがそのテーブルへアクセスするのをブロックしうる。ロックエスカレーションはトランザクション単位ではなく「その1文 (statement) が取得したロック数」で発火する。個々の文が取得する行・ページレベルのロックが 5,000 未満なら、トランザクション全体のロック数に関わらず昇格は起きない。

俺の解釈はこうです。「1文で 5,000 ロック超えるとテーブルごと固まる」なら、対策は2つに割れる。

ひとつはバッチを小さく刻む。同じ書籍の p.463 でも、5,000 ロック未満になるまでバッチを分ければエスカレーションを避けられる、ただし小さすぎるバッチは効率が落ちるからサイズは調整しろ、と書かれています。実務だと数千〜数万行ずつ WHILE ループで回す感じですね。

もうひとつは、逆に最初からテーブルロックを取りに行くやり方。WITH (TABLOCK) を付けて、復旧モデルが SIMPLE か BULK_LOGGED ならミニマムロギングが効いて、ログ書き込みも軽くなります。

-- 移行用の「空テーブル」へ大量に入れる時は TABLOCK でミニマムロギングを狙う
-- (復旧モデルが SIMPLE / BULK_LOGGED で、対象が空ヒープ/クラスタ化表の時に効果が出る)
CREATE TABLE dbo.Orders_Bulk (
    OrderId   INT           NOT NULL PRIMARY KEY,
    CustName  NVARCHAR(50)  NOT NULL,
    Amount    DECIMAL(10,2) NOT NULL,
    OrderDate DATE          NOT NULL
);

INSERT INTO dbo.Orders_Bulk WITH (TABLOCK)
    (OrderId, CustName, Amount, OrderDate)
SELECT OrderId, CustName, Amount, OrderDate
FROM dbo.Orders_2024;

どっちを選ぶかは時間帯で決める。業務時間帯に他のセッションを止めたくないならバッチ分割。夜間バッチで一気に入れたいなら TABLOCK。現場のスケジュールに合わせると、いい感じに収まります。

③ IDENTITY 列に元の値を保持したいなら IDENTITY_INSERT が要る

移行先に IDENTITY 列があって、「元の ID 番号をそのまま引き継ぎたい」時。

普通に INSERT すると、IDENTITY が勝手に採番し直して、元の ID と変わってしまいます。

元の値を保持したいなら SET IDENTITY_INSERT を ON にして、列リストに IDENTITY 列も明示します。

-- IDENTITY 列に元の ID を保持して入れる
CREATE TABLE dbo.Src_Id  (Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(20) NOT NULL);
CREATE TABLE dbo.Dest_Id (Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(20) NOT NULL);
INSERT INTO dbo.Src_Id (Name) VALUES (N'一郎'), (N'二郎');

SET IDENTITY_INSERT dbo.Dest_Id ON;   -- ← 元のIDを入れる許可
INSERT INTO dbo.Dest_Id (Id, Name)    -- ← Id も列リストに含める
SELECT Id, Name FROM dbo.Src_Id;
SET IDENTITY_INSERT dbo.Dest_Id OFF;  -- ← OFF に戻すのを忘れない

SELECT * FROM dbo.Dest_Id;

実行結果:

IDENTITY_INSERT で元IDを保持した実行結果 — Dest_Id に Id=1 一郎 / Id=2 二郎 が再採番されず入った証跡

SET IDENTITY_INSERT ... ON は同時に1テーブルしか効きません。終わったら OFF に戻すのを忘れずに。

戻し忘れると、次の普通の INSERT で「IDENTITY_INSERT が ON のままだから値を明示しろ」って怒られます。ん?さっき ON にしただけやのに?ってなるやつ。これ地味にハマります。

現場メモ: データ移行で時間を溶かさないために

俺が業務系の保守で学んだのは、移行系のスクリプトは「本番でしか踏まない罠」が多いってことです。

開発機だと数十行のサンプルでサクッと通る。けど本番は桁が違う。

ロックエスカレーションも、トランザクションログの肥大も、行数が増えて初めて顔を出します。開発機で SELECT INTO 一発で通したスクリプトが、本番の数百万行で深夜にログを食い潰して止まる。これ、わりとあるあるなんですよね。

だから移行スクリプトを書く時は、最初から「本番の行数で何が起きるか」を一行でいいからコメントに書いておく。「ここは TABLOCK 前提」「ここは復旧モデル SIMPLE で実行」みたいに。未来の自分か、引き継いだ後輩が救われます。

あと地味に効くのが、移行前にいったん SELECT COUNT(*) で件数を控えておくこと。

入れた後の件数と突き合わせれば、欠損や二重投入にその場で気づける。数秒の手間で、翌朝の「件数が合わない」騒ぎを丸ごと防げます。

まとめ

SQL Server で SELECT 結果を別テーブルに入れる3パターン、整理するとこうです。

  • 既存テーブルへ追記・移行INSERT INTO 既存表 (列…) SELECT …。列はきっちり明示
  • 器ごと素早く複製SELECT … INTO 新表 …。ただし制約・INDEX・IDENTITY は引き継がない
  • 加工してから戻す退避 → 一時テーブル #temp 経由。本番データを直接いじらず安全

そして大量行は、ロックエスカレーション (1文 5,000 ロックが分かれ目) を意識して、バッチ分割か TABLOCK で。IDENTITY を保持したいなら IDENTITY_INSERT を忘れずに。

「どっちで入れる?」で手が止まらなくなれば、移行の夜は少し短くなります。

よくある質問

Q1. INSERT INTO … SELECT と SELECT INTO、結局どっちを使えばいい?

入れ先テーブルが既にあるなら INSERT INTO ... SELECTまだ無くて器ごと作りたいなら SELECT ... INTO です。ただし SELECT INTO は制約や INDEX を引き継がないので、制約も移したい時は CREATE TABLE してから INSERT INTO ... SELECT が安全です。

Q2. SELECT INTO で作ったテーブルに PRIMARY KEY が無いのはなぜ?

SELECT ... INTO がコピーするのは列の型と NULL 許容だけだからです。PRIMARY KEY、INDEX、既定値、IDENTITY、外部キーは引き継がれません。制約が要るなら、後から ALTER TABLE で追加するか、最初から CREATE TABLE で定義してください。

Q3. なぜ一時テーブル経由をわざわざ挟むの?直接 INSERT じゃダメ?

加工が不要なら直接で構いません。一時テーブルを挟む価値は「本番テーブルを直接いじらずに加工・検証できる」点です。途中で失敗しても本番データは無傷なので、変換やクレンジングを伴う移行では安全弁になります。逆に、ただの全コピーで挟むのは tempdb を無駄に使うだけなので避けましょう。

Q4. 大量行を INSERT INTO … SELECT する時、トランザクションを分けるべき?

行数が多くてロックエスカレーションやログ肥大が心配なら、バッチに分けるのが定石です。Pro SQL Server Internals でも、1文あたり 5,000 ロック未満になるようバッチを刻めばエスカレーションを避けられるとされています。空テーブルへ一気に入れる夜間バッチなら、逆に WITH (TABLOCK) でミニマムロギングを狙う手もあります。

関連記事

以上!

同じ移行の罠でハマってる人いたら、どんどんシェア待ってるぜ!!


この記事の参考文献

ここまでの知見は以下の書籍から引用しています。業務SE視点で再構成していますが、元の体系的な知識を学ぶには直接読むのがおすすめです。

📖 『Pro SQL Server Internals』(著: Dmitri Korotkevitch)

引用範囲: p.459 / p.463(ロックエスカレーションの章)
本の特徴: SQL Server の内部構造(ストレージ / インデックス / トランザクション / ロック / 統計情報 / 実行計画)を DBA レベルで深掘りした技術書。大量データ移行で遭遇するロック・性能問題の根本原因を理解するためのバイブル。
こんな人におすすめ: 業務SE / DBA / SQL Server 案件担当・パフォーマンスチューニングを真面目にやりたいエンジニア


執筆者

バイブス父さん — 業務SE 7年(SIer 正社員2 / フリーランス5)。現職は SEO 直轄部の AI アドバイザー兼 PL、副業で中小 SIer の CTO。SIer の正社員からフリーランスに転じ、複数のエージェント経由で案件を回してきた経験ベースで「業務SE視点」の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524(日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次