Deep Dive — INSERT SELECT
The database copies rows faster than any loop can: one statement, one pass, and with
APPENDit never even touches the buffer cache.
1. What It Is and When
INSERT ... SELECT inserts the result of a query into a table. Anything you would do with a cursor loop that only inserts — archiving, copying, transforming, splitting into several tables — is one of these statements.
Variants:
INSERT INTO t SELECT ...— single target.INSERT ALL INTO t1 ... INTO t2 ... SELECT ...— every row goes to every target (multi-table insert).INSERT FIRST WHEN c1 THEN INTO t1 WHEN c2 THEN INTO t2 ELSE INTO t3 SELECT ...— each row goes to the first matching target.CREATE TABLE t AS SELECT ...(CTAS) — when the target does not exist yet; direct-path by default,NOLOGGINGoptional.
2. Syntax
INSERT [hint] INTO <table> [(<col>, ...)]
SELECT ... FROM ... [WHERE ...]
[LOG ERRORS [INTO <err_table>] [(<tag>)] [REJECT LIMIT { <n> | UNLIMITED }]];
-- multi-table
INSERT [ALL | FIRST]
[WHEN <cond> THEN] INTO <t1> [(<cols>)] VALUES (<exprs>)
[WHEN <cond> THEN] INTO <t2> [(<cols>)] VALUES (<exprs>)
[ELSE INTO <t3> ...]
SELECT ... FROM ...;
-- new table
CREATE TABLE <t> [NOLOGGING] [PARALLEL n] AS SELECT ... ;
RETURNING INTO is not available with INSERT ... SELECT — only with single-row INSERT ... VALUES.
3. Examples on the Course Schema
3a. Archive — always list the columns
INSERT INTO orders_archive (order_id, customer_id, order_date, amount, status)
SELECT order_id, customer_id, order_date, amount, status
FROM orders
WHERE order_date < DATE '2025-01-01';
INSERT INTO orders_archive SELECT * FROM orders works until someone adds a column to one table and not the other. Listing columns turns that into a compile error instead of shifted data.
3b. Direct path + parallel for the big one
ALTER SESSION ENABLE PARALLEL DML;
INSERT /*+ APPEND PARALLEL(a, 8) */ INTO orders_archive a
(order_id, customer_id, order_date, amount, status)
SELECT /*+ PARALLEL(o, 8) */ order_id, customer_id, order_date, amount, status
FROM orders o
WHERE order_date < DATE '2025-01-01';
COMMIT; -- required before reading orders_archive in this session (ORA-12838)
See APPEND HINT and PARALLEL DML for the mechanics and restrictions.
3c. Split one scan into two tables — INSERT FIRST
INSERT FIRST
WHEN status = 'CANCELLED' THEN
INTO orders_cancelled (order_id, customer_id, order_date, amount)
VALUES (order_id, customer_id, order_date, amount)
WHEN order_date < DATE '2025-01-01' THEN
INTO orders_archive (order_id, customer_id, order_date, amount, status)
VALUES (order_id, customer_id, order_date, amount, status)
SELECT order_id, customer_id, order_date, amount, status
FROM orders
WHERE status = 'CANCELLED' OR order_date < DATE '2025-01-01';
One scan of orders, two targets. The loop version would scan once and insert row by row into two tables; the two-statement version would scan twice.
3d. Transform on the way in — sequences and expressions
INSERT INTO order_summary (summary_id, customer_id, order_month, total_amount, order_count)
SELECT order_summary_seq.NEXTVAL,
customer_id,
TRUNC(order_date, 'MM'),
SUM(amount),
COUNT(*)
FROM orders
WHERE order_date >= DATE '2025-01-01'
GROUP BY customer_id, TRUNC(order_date, 'MM');
NEXTVAL in the select list is allowed (not inside DISTINCT, GROUP BY, ORDER BY or subqueries — wrap the aggregate in an inline view if needed).
3e. New table from a query — CTAS
CREATE TABLE orders_2024 NOLOGGING PARALLEL 8 AS
SELECT /*+ PARALLEL(o, 8) */ *
FROM orders o
WHERE order_date >= DATE '2024-01-01' AND order_date < DATE '2025-01-01';
Direct-path, no undo for the rows, minimal redo with NOLOGGING. Back it up afterwards.
3f. Do not stop on bad rows — LOG ERRORS
EXEC DBMS_ERRLOG.CREATE_ERROR_LOG('ORDERS_ARCHIVE');
INSERT INTO orders_archive (order_id, customer_id, order_date, amount, status)
SELECT order_id, customer_id, order_date, amount, status FROM orders_stage
LOG ERRORS INTO err$_orders_archive ('stage load') REJECT LIMIT UNLIMITED;
4. What Breaks at Scale
- Conventional insert of 40M rows goes through the buffer cache block by block, generates full undo and redo, and maintains every index row by row. Direct path (
APPEND) skips the cache and the undo;NOLOGGINGskips most redo. - Indexes. For a load that adds a large fraction of the table, it is usually faster to mark indexes
UNUSABLE, load, thenREBUILD ... NOLOGGING PARALLEL. Direct path maintains indexes in bulk at the end anyway, but a rebuild of a big index is cheaper than a bulk merge into it. - One transaction. A 40M-row conventional insert is one transaction: undo sizing, and a failure at row 39M rolls back everything. Direct path needs almost no undo. Chunking (partition by partition, or DBMS_PARALLEL_EXECUTE) gives restartability.
- Triggers fire per row for conventional and direct-path alike — and an enabled trigger silently turns
APPENDinto a conventional insert.
5. Pitfalls
INSERT INTO t SELECT *without a column list — positional mapping breaks the day a column is added.ORA-12838— reading a table in the same session after a direct-path insert, beforeCOMMIT.NOLOGGINGon a database inFORCE LOGGINGmode (every Data Guard setup) does nothing — and where it does work, the table is unrecoverable until the next backup.NEXTVALinsideGROUP BY/DISTINCTqueries:ORA-02287. Aggregate in an inline view,NEXTVALoutside.INSERT ALLwithWHENclauses evaluates everyWHENfor every row;INSERT FIRSTstops at the first match. Choose deliberately.- Sequence gaps and
CACHE: a 40M-row insert withCACHE 20hits the sequence 2M times.ALTER SEQUENCE ... CACHE 10000for bulk loads. - A
SELECTwithORDER BYin anINSERT ... SELECTsorts for nothing — unless you are deliberately clustering rows for an index (ORDER BYthe index columns can help compression and clustering factor).