SQL INSERT INTO Explained: Single, Multi-Row and INSERT SELECT
INSERT INTO adds rows to a table. Name the columns, insert many rows at once, let defaults and auto-increment fill the rest, or copy rows with a SELECT.
- Course: SQL study plan
- Module: Modifying data
- Kind: Lesson
- Reading time: 14 min
- Runtime: MySQL 8 syntax on SQLite 3.49
How do you insert data into a table in SQL?
Use INSERT INTO table (col1, col2) VALUES (v1, v2);. Name the columns explicitly so the statement survives schema changes; columns you leave out get their DEFAULT, NULL or the next AUTO_INCREMENT value. One statement can insert many rows with several parenthesised value lists, and INSERT INTO ... SELECT copies rows from a query instead of literal values.
Lesson
Everything in the earlier modules read data. From here on the statements change it, and the first one is INSERT: it adds new rows to a table. This lesson covers the single-row and multi-row forms, the column list and why it matters, what happens to the columns you leave out (defaults, NULL and AUTO_INCREMENT), how to read back a generated id, and INSERT INTO ... SELECT, which inserts the result of a query. The examples use MySQL 8 with InnoDB tables; the differences that come up in PostgreSQL interviews are called out where they matter.
The basic form
INSERT INTO products (id, name, category, price, in_stock)
VALUES (101, 'Desk lamp', 'lighting', 24.99, 12);
The statement names the table, then a column list in parentheses, then VALUES with one value per listed column, in the same order. Strings and dates are quoted with single quotes ('2024-03-01'), numbers are not, and NULL is written bare. A single quote inside a string is doubled: 'O''Brien'.
You may leave out the column list:
INSERT INTO products VALUES (102, 'Floor lamp', 'lighting', 79.00, 4);
This only works when you supply a value for every column in the order the table declares them, and it is a habit to avoid. The day someone adds a column, the statement either fails ("Column count doesn't match value count") or quietly puts values into the wrong columns if the types happen to fit. Always write the column list; it doubles as documentation.
Many rows in one statement
INSERT INTO products (id, name, category, price, in_stock)
VALUES
(103, 'Bulb 9W', 'lighting', 3.50, 200),
(104, 'Bulb 12W', 'lighting', 4.25, 150),
(105, 'Extension', 'power', 15.00, 30);
Several value lists separated by commas insert several rows. It is faster than one statement per row (one parse, one round trip, one log flush under autocommit) and it is atomic: if row three violates a constraint, none of the three are inserted. Bulk loads of thousands of rows are usually batched this way, a few hundred or a thousand rows per statement.
The columns you leave out
A column missing from the column list gets, in order of preference:
- its
DEFAULTvalue, if the table declares one; - the next number, if it is an
AUTO_INCREMENTcolumn; NULL, if the column allows it;- otherwise an error in MySQL's default strict mode ("Field 'x' doesn't have a default value").
CREATE TABLE tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
subject VARCHAR(100) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'open',
assignee VARCHAR(40)
);
INSERT INTO tickets (subject) VALUES ('Printer jams'), ('VPN drops');
SELECT id, subject, status, assignee FROM tickets ORDER BY id;
| id | subject | status | assignee |
|---|---|---|---|
| 1 | Printer jams | open | NULL |
| 2 | VPN drops | open | NULL |
The id came from AUTO_INCREMENT, status from its default, assignee is NULL. Writing NULL explicitly for a column with a default does not give you the default — you asked for NULL, and a NOT NULL column refuses it. To ask for the default by name, MySQL accepts the keyword DEFAULT in a value list (VALUES (DEFAULT, 'x')); leaving the column out of the list is the portable way.
Generated ids and LAST_INSERT_ID
When the database numbers the rows, the application usually needs the number it just got — to insert the order's lines, say. In MySQL:
INSERT INTO orders (customer_id, placed_on) VALUES (42, '2024-05-01');
SELECT LAST_INSERT_ID(); -- the id generated for that row
LAST_INSERT_ID() is per connection, so two sessions inserting at once each see their own value — never compute "the next id" with SELECT MAX(id) + 1, which two sessions can read at the same moment. After a multi-row insert it returns the id of the first row. In PostgreSQL the idiom is INSERT ... RETURNING id, which returns the generated columns as a result set; MySQL has no RETURNING.
Two facts about InnoDB's counter that interviewers like: ids are not guaranteed to be gap-free (a rolled-back insert still consumes its number), and a deleted id is never handed out again — the counter only moves forward. Treat surrogate ids as labels, not as a count of rows.
Copying rows with INSERT ... SELECT
Instead of literal values, an INSERT can take the rows of a query:
INSERT INTO orders_archive (id, customer, total, placed_on)
SELECT id, customer, total, placed_on
FROM orders
WHERE placed_on < '2023-01-01';
The SELECT can be anything — joins, GROUP BY, DISTINCT — as long as it returns one column per listed column. The match is by position, not by name: the SELECT's third column goes into the INSERT's third column whatever either is called, so keep the two lists in the same order. This is how you archive, fill a summary table, or move rows from a staging table after an import:
INSERT INTO daily_sales (sale_date, orders, revenue)
SELECT placed_on, COUNT(*), SUM(total)
FROM orders
GROUP BY placed_on;
A close cousin creates the table and fills it in one go: CREATE TABLE top_customers AS SELECT .... It copies column names and types from the query but not keys, indexes or defaults, so it suits scratch and reporting tables rather than real schema (the schema-design module returns to it).
When an INSERT fails
An insert that breaks a rule is refused and changes nothing:
- a duplicate value in a
PRIMARY KEYorUNIQUEcolumn — "Duplicate entry '7' for key 'PRIMARY'"; NULL(or a missing value with no default) in aNOT NULLcolumn;- a value that fails a
CHECKconstraint (MySQL 8.0.16 and later); - in strict mode, a string too long for its
VARCHAR, or a value of the wrong type.
What to do with duplicates on purpose — skip them, overwrite them or update them — is the subject of the upserts lesson. One environment note: the practice engine here is MySQL syntax on SQLite, which does not truncate or reject an over-long string the way strict MySQL does, so the exercises never rely on that.
Pitfalls
- An
INSERTwithout a column list, which breaks when the table changes. - Expecting
NULLto mean "use the default". - Computing ids with
MAX(id) + 1instead ofAUTO_INCREMENT. - An
INSERT ... SELECTwhose two column lists are in different orders — the types often match, so nothing complains. - Thousands of single-row inserts under autocommit, each a separate transaction and log flush; batch them.
Key takeaways
INSERT INTO t (cols) VALUES (...), (...);— always the column list; many rows per statement.- Omitted columns take their DEFAULT, an AUTO_INCREMENT number or NULL; explicit NULL is not the default.
LAST_INSERT_ID()(MySQL) orRETURNING(PostgreSQL) for the generated id; neverMAX(id) + 1.INSERT INTO ... SELECTcopies query results, matched by position.- A constraint violation rejects the whole statement.
Common questions
Why should I always list the columns in an INSERT?
Without a column list the values must match every column of the table in its declared order. Add a column later and every such INSERT breaks or, worse, puts a value in the wrong column. A column list documents intent, lets defaults fill the rest and keeps working when the table grows.
How do I insert multiple rows at once in MySQL?
Put several value lists after VALUES, separated by commas: INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y'), (3, 'z');. It is one statement, so it is parsed once, sent in one round trip and, under InnoDB, either all rows go in or none do if one fails.
How do I get the id of the row I just inserted in MySQL?
Call LAST_INSERT_ID() right after the INSERT on the same connection. It returns the first AUTO_INCREMENT value generated by the most recent INSERT in that session, so it is safe under concurrency. PostgreSQL uses INSERT ... RETURNING id instead, and drivers expose the value directly.
What is INSERT INTO SELECT used for?
It inserts the rows a query returns rather than literal values: archiving old rows, filling a summary table, copying from a staging table after an import. The SELECT's columns are matched to the INSERT's column list by position, not by name, so list both in the same order.
Exercises
Stock a new product line
The shop is adding three products. Insert them into products, always with an explicit column list — the first two in one multi-row INSERT:
| id | name | category | price | in_stock |
|---|---|---|---|---|
| 201 | USB-C cable | cables | 9.99 | 40 |
| 202 | HDMI cable | cables | 12.50 | 25 |
| 203 | Cable ties | accessories | 3.00 | (not given — use the column's default) |
Leave in_stock out of the third product's column list so the table's DEFAULT applies — don't write the default's value yourself (it is not always 0). Then show every product: columns id, name, price, in_stock, ordered by id.
The table already holds:
| id | name | category | price | in_stock |
|---|---|---|---|---|
| 101 | Desk lamp | lighting | 24.99 | 12 |
| 102 | Floor lamp | lighting | 79.00 | 4 |
The judge prints numbers without trailing zeros (79.00 as 79, 12.50 as 12.5).
id | name | price | in_stock
101 | Desk lamp | 24.99 | 12
102 | Floor lamp | 79 | 4
201 | USB-C cable | 9.99 | 40
202 | HDMI cable | 12.5 | 25
203 | Cable ties | 3 | 0Archive last year's orders
Copy every order placed before 2024-01-01 from orders into orders_archive with one INSERT ... SELECT, setting archived_on to '2024-06-30' for each copied row. Do not change orders. Then show the archive: columns id, customer, total, archived_on, ordered by id.
orders:
| id | customer | total | placed_on |
|---|---|---|---|
| 1 | Asha | 120.00 | 2023-11-02 |
| 2 | Ben | 45.50 | 2023-12-31 |
| 3 | Chen | 80.00 | 2024-01-01 |
| 4 | Asha | 15.25 | 2024-02-14 |
| 5 | Dev | 60.00 | 2023-07-19 |
orders_archive has the same columns plus archived_on DATE, and starts empty here (it may already hold rows — keep them).
id | customer | total | archived_on
1 | Asha | 120 | 2024-06-30
2 | Ben | 45.5 | 2024-06-30
5 | Dev | 60 | 2024-06-30