Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 10 – Changing data: INSERT, UPDATE, DELETE and UPSERT

INSERT, defaults and getting generated keys

Add rows with INSERT and a column list, copy rows with INSERT … SELECT, rely on defaults, and read back generated keys in PostgreSQL, MySQL and SQLite.

  • Beginner
  • 20 minutes
  • Examples run with sql.js 1.14.2 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Add one row or many rows with INSERT and an explicit column list
  • Copy the rows of a query into a table with INSERT … SELECT
  • Predict when a column gets its DEFAULT and when it gets NULL
  • Read back generated keys with RETURNING, LAST_INSERT_ID() or last_insert_rowid()

Before you start

A shop that signs up 3 customers in one statement still needs each new customer’s number before it can store their first orders, and asking the table for its largest number afterwards can return someone else’s. INSERT adds rows: after the table’s name you list, in parentheses, the columns you give values for, and after VALUES one list of values per new row, in the same order. A column you leave out gets its column default: The value a column's DEFAULT clause gives a new row when the INSERT leaves that column out of its column list. An explicit NULL is a value, so it never brings the default in., or NULL when it has none; INSERT … SELECT adds the rows a query returns; and the generated key: A key the database chooses for a new row, from an identity column or sequence, an AUTO_INCREMENT counter or SQLite's rowid. It is unique, but it is not a count of rows and it can have gaps. comes back from the INSERT itself, with a RETURNING: A clause that makes an INSERT, UPDATE or DELETE return the rows it changed, with the columns it names, in the same round trip. PostgreSQL and SQLite have it; MySQL does not. clause in PostgreSQL and SQLite and with LAST_INSERT_ID() in MySQL.

The scripts on this page add customers and products to mart, the practice shop of this track, and sql.js 1.14.2, a browser build of SQLite 3.49.1, executes them. Two programs near the end run the same statements on PostgreSQL 18.6 and MySQL 8.4.11 and show, as each server printed it, where those engines differ.

One INSERT adds one row or many, matched to its column list

Asha Kulkarni joins mart first, then three customers arrive at once. Press Run and read the table the script prints:

One customer, then three in one statement SQL · insert_rows.sql
-- mart's customers table, with two columns that have a default.
CREATE TABLE customers (
  customer_id  INTEGER PRIMARY KEY,
  name         TEXT    NOT NULL,
  city         TEXT    NOT NULL,
  plan         TEXT    NOT NULL DEFAULT 'basic',
  credit_paise INTEGER NOT NULL DEFAULT 0      -- 100 paise = 1 rupee
);

-- One row: name the columns, then give one value per column, in the same order.
INSERT INTO customers (customer_id, name, city)
VALUES (1, 'Asha Kulkarni', 'Pune');

-- Three rows in one statement: one list of values per row.
INSERT INTO customers (customer_id, name, city, plan)
VALUES (2, 'Vikram Nair',   'Kochi',   'plus'),
       (3, 'Meera Rathore', 'Jaipur',  'basic'),
       (4, 'Tenzin Dorjee', 'Gangtok', 'plus');

SELECT * FROM customers;

Output

┌─────────────┬───────────────┬─────────┬───────┬──────────────┐
│ customer_id │     name      │  city   │ plan  │ credit_paise │
├─────────────┼───────────────┼─────────┼───────┼──────────────┤
│ 1           │ Asha Kulkarni │ Pune    │ basic │ 0            │
│ 2           │ Vikram Nair   │ Kochi   │ plus  │ 0            │
│ 3           │ Meera Rathore │ Jaipur  │ basic │ 0            │
│ 4           │ Tenzin Dorjee │ Gangtok │ plus  │ 0            │
└─────────────┴───────────────┴─────────┴───────┴──────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < insert_rows.sql

The first INSERT names three columns and gives three values, matched one by one: 1 goes to customer_id, 'Asha Kulkarni' to name, 'Pune' to city. Text is written in single quotes and numbers without them. The second INSERT adds three rows at once, one parenthesised list per row, separated by commas. All three engines accept such a multi-row VALUES list, and it is faster than three separate statements, because the database parses one statement and the program waits for one answer instead of three. The two columns that no INSERT named, plan for Asha and credit_paise for everyone, got their defaults: 'basic' and 0.

Each value of a new row comes from one of three places: the statement’s own list, a column’s default, or the database’s key generator. The diagram follows one statement that uses all three:

One INSERT fills a new row: values for the listed columns, defaults for the rest, a generated key; RETURNING sends values back.INSERT INTO customers (name, city)VALUES ('Farhan Qureshi', 'Lucknow')RETURNING customer_id, planThe new row of customersRETURNING: 3 | basicsent back by the same statementcustomer_id = 3generated by the databasename, cityfrom VALUES, in the orderof the column listplan = 'basic'left out: its DEFAULTcredit_paise = 0left out: its DEFAULTone row per list of valuesonly the columns asked for

Where each value of a new row comes from

Text description of the diagram

The diagram follows one statement from top to bottom: INSERT INTO customers (name, city) VALUES ('Farhan Qureshi', 'Lucknow') RETURNING customer_id, plan.

  1. The statement names two columns, name and city, and gives one list of two values, so it makes one new row.
  2. In the new row, customer_id is 3: the statement gave no value for the key, so the database generated the next number.
  3. name and city get 'Farhan Qureshi' and 'Lucknow', matched to the column list by position.
  4. plan and credit_paise are not in the column list, so each gets its DEFAULT: 'basic' and 0.
  5. RETURNING sends back only the columns it asks for, here 3 and 'basic', as the result of the same statement, without a second query.

The column list protects the statement from table changes

SQL lets you leave the column list out. The values must then cover every column of the table, in the order the table defines them. That works until the table changes:

An INSERT without a column list, before and after the table changes SQL · positional_insert.sql
-- An INSERT without a column list depends on the table's column order.
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  city        TEXT    NOT NULL
);
INSERT INTO customers VALUES (1, 'Asha Kulkarni', 'Pune');   -- works today

-- A migration adds a column, and the same kind of statement stops working.
ALTER TABLE customers ADD COLUMN phone TEXT;
INSERT INTO customers VALUES (2, 'Vikram Nair', 'Kochi');

-- With a column list, it keeps working: phone is left out and stays NULL.
INSERT INTO customers (customer_id, name, city) VALUES (2, 'Vikram Nair', 'Kochi');
SELECT * FROM customers;

-- Worse: a rebuilt table lists city before name. Nothing fails, and the data is wrong.
CREATE TABLE customers_v2 (
  customer_id INTEGER PRIMARY KEY,
  city        TEXT    NOT NULL,
  name        TEXT    NOT NULL
);
INSERT INTO customers_v2 VALUES (3, 'Meera Rathore', 'Jaipur');
SELECT customer_id, name, city FROM customers_v2;

Output (exit status 1)

┌─────────────┬───────────────┬───────┬───────┐
│ customer_id │     name      │ city  │ phone │
├─────────────┼───────────────┼───────┼───────┤
│ 1           │ Asha Kulkarni │ Pune  │       │
│ 2           │ Vikram Nair   │ Kochi │       │
└─────────────┴───────────────┴───────┴───────┘
┌─────────────┬────────┬───────────────┐
│ customer_id │  name  │     city      │
├─────────────┼────────┼───────────────┤
│ 3           │ Jaipur │ Meera Rathore │
└─────────────┴────────┴───────────────┘

Printed as an error (standard error)

Parse error near line 11: table customers has 4 columns but 3 values were supplied

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < positional_insert.sql

Step through what the short form did, value by value, before the migration, after it and on the rebuilt table:

Interactive · step by step How an INSERT without a column list pairs values with columns Step through the example's short INSERT before and after the table changes. Each value goes to the column in the same position.
How an INSERT without a column list pairs values with columns, step 7 of 7. Showing Value 1 (3), Value 2 ('Meera Rathore'), Value 3 ('Jaipur'), Column 1, Column 2 (city = 'Meera Rathore'), Column 3 (name = 'Jaipur'). In focus: Column 2, Column 3.Value 13Value 2'Meera Rathore'Value 3'Jaipur'Column 1customer_idColumn 2city = 'Meera Rathore'Column 3name = 'Jaipur'

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Three values, three columns. customers has three columns: customer_id, name and city. INSERT INTO customers VALUES (1, 'Asha Kulkarni', 'Pune') names no columns, so SQLite pairs the values with the columns in the order the table defines them.
  2. Paired by position. Value 1 goes to column 1, value 2 to column 2, value 3 to column 3. Today that order is right, and the row reads 1, Asha Kulkarni, Pune.
  3. A migration adds phone. ALTER TABLE customers ADD COLUMN phone TEXT gives the table a fourth column. The same short form, now for Vikram Nair in Kochi, still brings three values.
  4. Four columns, three values. SQLite refuses the statement: table customers has 4 columns but 3 values were supplied. This failure is the lucky case, because someone notices it.
  5. The column list fixes it. Written with its column list, INSERT INTO customers (customer_id, name, city) VALUES (2, 'Vikram Nair', 'Kochi') pairs each value with the column it names. phone is left out and stays NULL.
  6. A rebuilt table. customers_v2 defines city before name. The short form INSERT INTO customers_v2 VALUES (3, 'Meera Rathore', 'Jaipur') pairs value 2 with city and value 3 with name, and nothing fails.
  7. Name the columns in code. The example's SELECT shows Jaipur as the name and Meera Rathore as the city. With a column list in every INSERT that lives in code, the table's column order no longer matters.

After the migration added phone, the short form failed: four columns, three values. That failure is the lucky case, because someone notices it. The rebuilt table shows the unlucky one. Its columns come in a different order, both are text, and the same kind of statement put the city into name and the name into city without a word.

An INSERT without a column list, in code

INSERT INTO customers VALUES (2, 'Vikram Nair', 'Kochi') works only while the table keeps its columns and their order. Write INSERT INTO customers (customer_id, name, city) VALUES (2, 'Vikram Nair', 'Kochi') in every INSERT that lives in code or a script; leave the list out only in a one-off statement whose result you check.

Checkpoint

Check: Rows and the column list

  1. Question 1 of 2 INSERT INTO customers (customer_id, city, name) VALUES (5, 'Shillong', 'Banri Kharkongor') runs on the customers table of the first example, whose columns are customer_id, name, city, plan and credit_paise. What does the new row hold in name?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 'Banri Kharkongor': each value pairs with the column in the same place of the column list

    The values are matched against the column list one by one from the left. Here city is named second and name third, so 'Shillong' goes to city and 'Banri Kharkongor' to name. plan and credit_paise are left out and take their defaults, 'basic' and 0.

  2. Question 2 of 2 A nightly script runs INSERT INTO customers VALUES (7, 'Ishaan Bose', 'Kolkata') on a table of three columns. Today a migration adds phone TEXT at the end of the table. What happens tonight?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The INSERT fails: the table has 4 columns but the statement gives 3 values

    The short form depends on the table's columns and their order. The migration added a fourth column, so three values no longer cover them and the statement fails. With a column list the statement keeps working and leaves phone NULL.

One refused row undoes the whole statement

Three new customers arrive in one statement, and the third has no city:

Three rows, one without a city: INSERT, then INSERT OR IGNORE SQL · one_bad_row.sql
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  city        TEXT    NOT NULL
);
INSERT INTO customers (customer_id, name, city) VALUES (1, 'Asha Kulkarni', 'Pune');

-- Three new rows; the last one has no city.
INSERT INTO customers (customer_id, name, city)
VALUES (2, 'Vikram Nair',   'Kochi'),
       (3, 'Meera Rathore', 'Jaipur'),
       (4, 'Tenzin Dorjee', NULL);

-- The statement failed as a whole: the two good rows were taken back too.
SELECT count(*) AS customers FROM customers;

-- Asking to keep the good rows: SQLite's INSERT OR IGNORE skips the row that breaks a rule.
INSERT OR IGNORE INTO customers (customer_id, name, city)
VALUES (2, 'Vikram Nair',   'Kochi'),
       (3, 'Meera Rathore', 'Jaipur'),
       (4, 'Tenzin Dorjee', NULL);
SELECT customer_id, name, city FROM customers;

Output (exit status 1)

┌───────────┐
│ customers │
├───────────┤
│ 1         │
└───────────┘
┌─────────────┬───────────────┬────────┐
│ customer_id │     name      │  city  │
├─────────────┼───────────────┼────────┤
│ 1           │ Asha Kulkarni │ Pune   │
│ 2           │ Vikram Nair   │ Kochi  │
│ 3           │ Meera Rathore │ Jaipur │
└─────────────┴───────────────┴────────┘

Printed as an error (standard error)

Runtime error near line 9: NOT NULL constraint failed: customers.city

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < one_bad_row.sql

After the failed statement the table still holds one customer. Step through it to see where the first two new rows went:

Interactive · step by step One INSERT, three lists of values, one refused Step through the statement row by row. The rows it adds are not kept until the whole statement has succeeded.
One INSERT, three lists of values, one refused, step 5 of 5. Showing List 1, List 2, List 3, Row 1. In focus: Row 1.List 12, 'Vikram Nair', 'Kochi'List 23, 'Meera Rathore', 'Jaipur'List 34, 'Tenzin Dorjee', NULLRow 1Asha Kulkarni, Pune

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. One row, three lists. customers holds one row, Asha Kulkarni in Pune. One INSERT brings three lists of values, one per new row, and city is NOT NULL.
  2. List 1 becomes row 2. SQLite writes Vikram Nair's row. It belongs to a statement that has not finished, so it is not kept yet.
  3. List 2 becomes row 3. Meera Rathore's row follows in the same way, also waiting for the statement to end.
  4. List 3 is refused. The third list gives NULL for city. SQLite refuses it with "NOT NULL constraint failed: customers.city", and the statement fails.
  5. The whole statement undone. A statement succeeds or fails as a whole, so SQLite takes rows 2 and 3 back as well. count(*) returns 1, the table as it was before the INSERT.

A statement succeeds or fails as a whole: when one row of a multi-row INSERT is refused, the rows before it are taken back too. PostgreSQL and MySQL’s default InnoDB tables behave the same way.

To keep the good rows, a statement has to ask for it, and the two ways to ask do different things. SQLite’s INSERT OR IGNORE skips each row that breaks a NOT NULL, UNIQUE, CHECK or primary key rule, without an error or a warning: at the end of the script it added Vikram Nair and Meera Rathore and left Tenzin Dorjee out. MySQL’s INSERT IGNORE turns the errors into warnings, and a NULL in a NOT NULL column is not skipped at all: MySQL stores the implicit default of the column’s type instead, so the MySQL run near the end of this lesson keeps Tenzin Dorjee with an empty city. For loading data, checking the rows first and inserting the good ones is usually the better plan, because the rows left out can then be listed and fixed.

Checkpoint

Check: One refused row undoes the statement

  1. Question 1 of 2 customers holds 10 rows. One INSERT with five lists of values runs, and the fourth list breaks the NOT NULL rule on city. How many rows does customers hold afterwards?

    Type a number.

    A hint

    Think about the statement as a whole, not about the lists one by one.

    Show the answer: Question 1 of 2

    Answer: 10 rows

    A statement succeeds or fails as a whole. The error on the fourth list takes back the three rows already written, and the fifth list is never reached, so the table still holds its 10 rows. PostgreSQL, MySQL's InnoDB tables and SQLite all behave this way.

  2. Question 2 of 2 A nightly load of 2,000 customer rows must not fail because of a few broken rows, and someone must see which rows were left out and why. Which plan does that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Check the rows first, insert the good ones with one statement, and keep a list of the others with the reason

    Skipping rows has to be asked for, and the quiet ways to ask lose information. Checking the rows first and inserting the good ones keeps the load whole and leaves a list of what was left out, which a person can fix.

A column left out gets its default; NULL is a value

mart’s site records page visits, and every column but the key has a default. The script tries five ways of leaving a value to the database:

Five ways to leave a value to the database SQL · defaults.sql
-- Page visits of a shop's website: every column but the key has a default.
CREATE TABLE visits (
  visit_id INTEGER PRIMARY KEY,
  page     TEXT    NOT NULL DEFAULT '/',
  device   TEXT             DEFAULT 'phone',
  seconds  INTEGER NOT NULL DEFAULT 0
);

-- 1. Columns left out of the list get their default.
INSERT INTO visits (page) VALUES ('/learn/sql/');

-- 2. A row made of defaults only.
INSERT INTO visits DEFAULT VALUES;

-- 3. An explicit NULL is a value of its own: it replaces the default.
INSERT INTO visits (page, device) VALUES ('/tools/', NULL);

-- 4. A NOT NULL column refuses NULL, even when it has a default.
INSERT INTO visits (page, seconds) VALUES ('/about/', NULL);

-- 5. PostgreSQL and MySQL accept the keyword DEFAULT in a VALUES list; SQLite does not.
INSERT INTO visits (page, device) VALUES ('/blog/', DEFAULT);

SELECT * FROM visits;

Output (exit status 1)

┌──────────┬─────────────┬────────┬─────────┐
│ visit_id │    page     │ device │ seconds │
├──────────┼─────────────┼────────┼─────────┤
│ 1        │ /learn/sql/ │ phone  │ 0       │
│ 2        │ /           │ phone  │ 0       │
│ 3        │ /tools/     │        │ 0       │
└──────────┴─────────────┴────────┴─────────┘

Printed as an error (standard error)

Runtime error near line 19: NOT NULL constraint failed: visits.seconds
Parse error near line 22: near "DEFAULT": syntax error

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < defaults.sql

Step through the five statements and watch where each column of the new row gets its value:

Interactive · step by step Where each value of a new visit comes from Step through the five statements. Each column of the new row takes the value the statement gives, its default, or an error.
Where each value of a new visit comes from, step 6 of 6. Showing The INSERT (SELECT * FROM visits), visit_id (rows 1, 2 and 3), page (/learn/sql/, /, /tools/), device (phone, phone, NULL), seconds (0, 0, 0). In focus: visit_id.The INSERTSELECT * FROM visitsvisit_idrows 1, 2 and 3page/learn/sql/, /, /tools/devicephone, phone, NULLseconds0, 0, 0

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Three columns left out. INSERT INTO visits (page) VALUES ('/learn/sql/') names only page. device and seconds are left out of the column list, so each takes its default.
  2. DEFAULT VALUES. INSERT INTO visits DEFAULT VALUES names no column at all, so every column takes its default. visit_id has none, so SQLite numbers the row itself, 2.
  3. An explicit NULL. (page, device) VALUES ('/tools/', NULL) gives device a value, and NULL is that value. It replaces the default, so device is empty on row 3.
  4. NULL into NOT NULL. (page, seconds) VALUES with NULL for seconds is refused: "NOT NULL constraint failed: visits.seconds". A default does not rescue an explicit NULL.
  5. The keyword DEFAULT. VALUES ('/blog/', DEFAULT) asks for device's default by name. PostgreSQL and MySQL accept it; SQLite reports a syntax error near DEFAULT, so there you leave the column out.
  6. Three rows stored. The final SELECT shows rows 1 to 3. A default fills only a column the INSERT does not mention, and it applies only when the row is inserted.

Statement by statement:

  1. page was given; device and seconds were left out and got their defaults, 'phone' and 0.
  2. DEFAULT VALUES takes the default of every column. visit_id has none, so SQLite numbered the row itself.
  3. An explicit NULL is a value. It replaced the default, and device is empty on that row.
  4. seconds is NOT NULL, so NULL is refused there, default or not.
  5. PostgreSQL and MySQL accept the keyword DEFAULT in place of a value, as in VALUES ('/blog/', DEFAULT). SQLite reports a syntax error; leave the column out of the list instead.

MySQL has no DEFAULT VALUES. There a row of defaults is INSERT INTO visits () VALUES (): an empty column list and an empty list of values. A default does not have to be a constant. DEFAULT CURRENT_TIMESTAMP, for example, is worked out for each row at the moment it is inserted. The rule that catches people is the third statement: a program that always sends every column, and sends NULL for “no value”, never sees a default at all.

Key idea

A default fills only a column the INSERT leaves out of its column list. An explicit NULL is a value: it replaces the default, and a NOT NULL column refuses it, default or not.

Checkpoint

Check: Defaults against an explicit NULL

  1. Question 1 of 2 The column device is declared TEXT DEFAULT 'phone', without NOT NULL. What does INSERT INTO visits (page, device) VALUES ('/tools/', NULL) store in device?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: NULL

    A default is used only for a column the INSERT leaves out of its column list (or, in PostgreSQL and MySQL, for the keyword DEFAULT). An explicit NULL is a value, so it is stored. With NOT NULL on the column, the same statement would fail instead.

  2. Question 2 of 2 A form handler sends every column of visits in each INSERT and turns an empty field into NULL. A visitor's device is not known, so the field is empty. What does the new row hold in device?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: NULL: the INSERT names device and gives it NULL, so the default 'phone' never applies

    The default fills a column the INSERT does not mention. To get 'phone', the handler leaves device out of the column list for that row, or, in PostgreSQL and MySQL, sends the keyword DEFAULT in its place.

INSERT with SELECT copies a query’s rows by position

mart keeps a price history: once a month, the price of every product still on sale is copied into a second table.

This month's prices into the price history SQL · insert_select.sql
-- mart's products, and a history table that keeps each product's price per month.
CREATE TABLE products (
  product_id  INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  price_paise INTEGER NOT NULL,
  active      INTEGER NOT NULL DEFAULT 1        -- 0 = no longer sold
);
INSERT INTO products (product_id, name, price_paise, active)
VALUES (1, 'Basmati rice 1 kg', 14999, 1),
       (2, 'Toor dal 500 g',     8950, 1),
       (3, 'Masala chai 250 g', 12000, 1),
       (4, 'Coconut oil 1 L',   21500, 0),
       (5, 'Ragi flour 1 kg',    6800, 1);

CREATE TABLE price_history (
  product_id  INTEGER NOT NULL REFERENCES products (product_id),
  month       TEXT    NOT NULL,                 -- 'YYYY-MM'
  price_paise INTEGER NOT NULL,
  PRIMARY KEY (product_id, month)
);

-- One statement copies this month's price of every product still sold.
-- The SELECT list fills the column list by position: product_id, month, price_paise.
INSERT INTO price_history (product_id, month, price_paise)
SELECT product_id, '2026-10', price_paise
FROM products
WHERE active = 1;

SELECT changes() AS rows_copied;
SELECT * FROM price_history;

-- The same copy run a second time breaks the primary key, and copies nothing.
INSERT INTO price_history (product_id, month, price_paise)
SELECT product_id, '2026-10', price_paise FROM products WHERE active = 1;
SELECT count(*) AS rows_now FROM price_history;

Output (exit status 1)

┌─────────────┐
│ rows_copied │
├─────────────┤
│ 4           │
└─────────────┘
┌────────────┬─────────┬─────────────┐
│ product_id │  month  │ price_paise │
├────────────┼─────────┼─────────────┤
│ 1          │ 2026-10 │ 14999       │
│ 2          │ 2026-10 │ 8950        │
│ 3          │ 2026-10 │ 12000       │
│ 5          │ 2026-10 │ 6800        │
└────────────┴─────────┴─────────────┘
┌──────────┐
│ rows_now │
├──────────┤
│ 4        │
└──────────┘

Printed as an error (standard error)

Runtime error near line 33: UNIQUE constraint failed: price_history.product_id, price_history.month

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < insert_select.sql

The SELECT returned four rows, the inactive coconut oil left out, and each became a row of price_history. The month is a constant in the SELECT list, which is how you give every copied row the same value. Step through how the copy pairs the query’s values with the column list, and what a swap of two items does:

Interactive · step by step How INSERT with SELECT fills the column list Step through the monthly copy of prices, then the same copy with two items of the SELECT list swapped.
How INSERT with SELECT fills the column list, step 5 of 5. Showing SELECT item 1 (price_paise (swapped)), SELECT item 2, SELECT item 3 (product_id (swapped)), product_id (gets the prices), month (the same month, 4 times), price_paise (gets the keys). In focus: product_id, price_paise.SELECT item 1price_paise (swapped)SELECT item 2the month, a constantSELECT item 3product_id (swapped)product_idgets the pricesmonththe same month, 4 timesprice_paisegets the keysno constraint: wrong data, no error

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Two lists, side by side. INSERT INTO price_history (product_id, month, price_paise) names three columns, and the SELECT under it returns three values for each product that is still sold.
  2. First with first. The values fill the column list by position: item 1 goes to the first column named, item 2 to the second, item 3 to the third. Their names play no part.
  3. Four rows copied. The SELECT returned the 4 active products, so changes() reports 4 rows. The month is a constant in the SELECT list, which gives every copied row the same month.
  4. Two items swapped. Now the SELECT lists price_paise first and product_id third. Nothing compares names, so item 1, the price, goes to product_id, and item 3, the key, goes to price_paise.
  5. Only a constraint objects. Both are whole numbers, so the types fit. An enforced foreign key on product_id refuses 14999, as no product has that id; a plain integer column stores it without a word.

The SELECT’s columns fill the column list by position, not by name: the first goes to product_id, the second to month, the third to price_paise, whatever they are called. Only a constraint can catch a swap, such as an enforced foreign key that finds no product numbered 14999.

Running the copy a second time failed on the primary key, and, as before, it copied nothing at all. A copy that may run twice, such as a monthly job that is retried, needs an insert that skips or updates the rows that already exist: an upsert, the subject of a later lesson of this module. INSERT … SELECT needs a table that exists. CREATE TABLE … AS SELECT makes a new table from a query instead, with the query’s columns; in SQLite such a table has no primary key and no constraints of any kind.

Checkpoint

Check: INSERT with SELECT, by position

  1. Question 1 of 2 What does INSERT INTO price_history (product_id, month, price_paise) SELECT price_paise, '2026-10', product_id FROM products put into price_history.product_id, if no constraint objects?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each product's price, because the SELECT list fills the column list by position

    INSERT with SELECT matches the SELECT's columns to the column list one by one, from left to right; their names play no part. With the two swapped, prices land in product_id and keys in price_paise. Only a constraint can catch it: an enforced foreign key refuses a product_id of 14999 when no such product exists.

  2. Question 2 of 2 price_history holds this month's 4 rows, and its primary key is (product_id, month). The monthly job is retried and runs the same INSERT with SELECT again. How many rows does price_history hold afterwards?

    Type a number.

    A hint

    Every row the second copy proposes is already there.

    Show the answer: Question 2 of 2

    Answer: 4 rows

    Each proposed row repeats a primary key that already exists, so the statement fails on the first of them, and a statement succeeds or fails as a whole: none of the 4 rows is added again. The example's second run printed 4 after the error. A job that may run twice needs an upsert, which skips or updates the rows that exist.

The new key comes back from the INSERT itself

Most tables let the database choose the key of a new row:

  • SQLite numbers a row whose INTEGER PRIMARY KEY is left out: by default the largest key in the table plus one. With AUTOINCREMENT it never hands out a number twice.
  • PostgreSQL declares GENERATED ALWAYS AS IDENTITY (or the older serial), and a A database object that hands out increasing numbers, one at a time. PostgreSQL's identity columns take their keys from one, and a number it has handed out is never given back, even when the INSERT fails. gives out the numbers.
  • MySQL declares AUTO_INCREMENT, and InnoDB keeps a counter for the table.

The program that inserted the row needs the new key straight away, for example to insert the items of an order it has just created. Asking the table afterwards is the trap. Step through two sign-ups that arrive at the same moment on two connections:

Interactive · message by message Why max(customer_id) can read someone else's key Step through two sign-ups on two connections, first with a second query for the key, then with RETURNING.
Why max(customer_id) can read someone else's key, step 6 of 6. Messages so far: Request A to Database: INSERT Farhan Qureshi; Request B to Database: INSERT Lalremruati; Request A to Database: SELECT max(customer_id); Database to Request A: 4: B's key, not A's; Database to Request A: RETURNING: 3; Request A to Database: SELECT LAST_INSERT_ID().Request Aknows its own key: 3Databasecustomer 4: LalremruatiRequest Bsigns up LalremruatiINSERT Farhan QureshiINSERT LalremruatiSELECT max(customer_id)4: B's key, not A'sRETURNING: 3SELECT LAST_INSERT_ID()

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A inserts. Request A inserts Farhan Qureshi without a customer_id. The database numbers the row 3, the next key, and the INSERT ends.
  2. B inserts in between. Before A sends its next statement, request B inserts Lalremruati on its own connection. That row gets 4.
  3. A asks for the largest key. Request A now runs SELECT max(customer_id) to learn its new number. The largest key in the table is 4, the one B just added.
  4. The wrong number. The answer is 4. A stores Farhan's welcome credit and first order under customer 4, which is Lalremruati. No error is raised anywhere.
  5. The key from the INSERT. Written as INSERT … RETURNING customer_id, the statement that creates the row also sends back its key, in the same round trip. Nothing B does can come between.
  6. MySQL keeps it per connection. MySQL has no RETURNING. SELECT LAST_INSERT_ID() on A's own connection returns the key of A's last INSERT, 3, whatever B inserts on another connection.

Between request A’s INSERT and its SELECT max(customer_id), request B added a customer of its own, so A read B’s key, and no error was raised anywhere. The key has to come from the INSERT itself:

RETURNING, last_insert_rowid() and changes() SQL · returning.sql
-- Two customers exist; the database numbers the next ones itself.
CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  city        TEXT    NOT NULL,
  plan        TEXT    NOT NULL DEFAULT 'basic'
);
INSERT INTO customers (customer_id, name, city)
VALUES (1, 'Asha Kulkarni', 'Pune'), (2, 'Vikram Nair', 'Kochi');

-- No customer_id given: SQLite chooses it, and RETURNING sends the new row back.
INSERT INTO customers (name, city) VALUES ('Farhan Qureshi', 'Lucknow')
RETURNING customer_id, name, plan;

-- One RETURNING row for each row inserted.
INSERT INTO customers (name, city)
VALUES ('Lalremruati', 'Aizawl'), ('Gurpreet Kaur', 'Amritsar')
RETURNING customer_id, name;

-- The connection also keeps the last new rowid and the rows the last statement changed.
SELECT last_insert_rowid() AS last_id, changes() AS rows_changed;

Output

┌─────────────┬────────────────┬───────┐
│ customer_id │      name      │ plan  │
├─────────────┼────────────────┼───────┤
│ 3           │ Farhan Qureshi │ basic │
└─────────────┴────────────────┴───────┘
┌─────────────┬───────────────┐
│ customer_id │     name      │
├─────────────┼───────────────┤
│ 4           │ Lalremruati   │
│ 5           │ Gurpreet Kaur │
└─────────────┴───────────────┘
┌─────────┬──────────────┐
│ last_id │ rows_changed │
├─────────┼──────────────┤
│ 5       │ 2            │
└─────────┴──────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < returning.sql

RETURNING turns an INSERT into a statement that also returns rows: one per inserted row, with the columns you ask for. Values the database filled in, the key and the default plan, come back with the rest, in the same round trip. SQLite has had RETURNING since version 3.35, and PostgreSQL has it on INSERT, UPDATE, DELETE and MERGE. Do not rely on the order of the returned rows: SQLite’s manual calls it arbitrary. When the order matters, return a column that identifies each row, such as its name. SQLite also keeps two numbers per connection: last_insert_rowid(), the key of the last row inserted, and changes(), how many rows the last statement inserted, changed or deleted.

MySQL has no RETURNING; INSERT … RETURNING customer_id is a syntax error there. After the INSERT, the same connection runs SELECT LAST_INSERT_ID(). Two details matter:

  • The value belongs to the connection. Rows other connections insert in the meantime never change it, so it is safe under load.
  • After a multi-row INSERT, it returns the key of the first row of the statement, not the last one, and ROW_COUNT() tells how many rows were added.

In PostgreSQL, RETURNING is the usual answer; the functions lastval() and currval() also give the last number a sequence handed out in your session.

Key idea

Take a new row’s key from the statement that created it, with RETURNING, or with LAST_INSERT_ID() on the same connection in MySQL, and never from a second look at the whole table, such as SELECT max(customer_id).

Checkpoint

Check: Reading back the generated key

  1. Question 1 of 2 Two requests insert a customer at the same moment, each on its own connection, and each then runs SELECT max(customer_id) to learn its new key. What can request A read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: B's key, when B inserted between A's INSERT and A's SELECT

    Between A's two statements, B's INSERT can add a row with a larger key, and max() returns that one, as in the stepped sign-ups, where A read 4, Lalremruati's key. RETURNING sends A its own key from the INSERT itself; in MySQL, LAST_INSERT_ID() on A's connection does the same.

  2. Question 2 of 2 In MySQL, the largest key of a table with an AUTO_INCREMENT key is 100. One INSERT adds three rows, which get the keys 101, 102 and 103. What does SELECT LAST_INSERT_ID() return right after it, on the same connection?

    Type a number.

    A hint

    The function reports one key of the statement, not the last row.

    Show the answer: Question 2 of 2

    Answer: 101

    After a multi-row INSERT, MySQL's LAST_INSERT_ID() returns the key generated for the first row of the statement, not the last one; ROW_COUNT() tells how many rows were added. In the MySQL run of this lesson, a two-row insert reported 1 and 2.

Generated keys are unique, not consecutive

Generated keys are unique, and that is all they promise: they are not a count of rows, and they are not guaranteed to be consecutive. Two tables of support tickets lose their newest ticket, and each gets a new one:

The same delete, with and without AUTOINCREMENT SQL · key_reuse.sql
-- Two tables of support tickets, one with AUTOINCREMENT and one without.
CREATE TABLE tickets      (ticket_id INTEGER PRIMARY KEY,               subject TEXT NOT NULL);
CREATE TABLE tickets_auto (ticket_id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT NOT NULL);

INSERT INTO tickets      (subject) VALUES ('Refund'), ('Late delivery'), ('Wrong item');
INSERT INTO tickets_auto (subject) VALUES ('Refund'), ('Late delivery'), ('Wrong item');

-- Ticket 3, the newest, is deleted in both tables.
DELETE FROM tickets      WHERE ticket_id = 3;
DELETE FROM tickets_auto WHERE ticket_id = 3;

-- The next ticket: largest key + 1, or a number never used before.
INSERT INTO tickets      (subject) VALUES ('Damaged box') RETURNING ticket_id AS plain_key;
INSERT INTO tickets_auto (subject) VALUES ('Damaged box') RETURNING ticket_id AS autoincrement_key;

Output

┌───────────┐
│ plain_key │
├───────────┤
│ 3         │
└───────────┘
┌───────────────────┐
│ autoincrement_key │
├───────────────────┤
│ 4                 │
└───────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < key_reuse.sql

Without AUTOINCREMENT, SQLite gave the next ticket the number 3 again, because after the delete the largest key was 2. A link or an email that still names “ticket 3” now points at a different ticket. With AUTOINCREMENT, the next number was 4.

PostgreSQL’s sequences and MySQL’s counters only move forward, so they leave gaps instead: a number taken by an INSERT that failed or was rolled back is not given out again. In the PostgreSQL run of the next section, a rolled-back insert took 4, a failed two-row insert took 5 and 6, and the next customer got 7. Treat a gap as normal, and never as a sign that a row went missing.

Checkpoint

Check: Keys are unique, not consecutive

  1. Question 1 of 2 A SQLite table has ticket_id INTEGER PRIMARY KEY without AUTOINCREMENT and holds the tickets 1, 2 and 3. Ticket 3 is deleted, and a new ticket is inserted without a ticket_id. Which number does it get?

    Type a number.

    A hint

    Without AUTOINCREMENT, SQLite looks at the largest key that is still in the table.

    Show the answer: Question 1 of 2

    Answer: 3

    Without AUTOINCREMENT, SQLite gives a new row the largest key in the table plus one, and after the delete the largest key is 2. With AUTOINCREMENT it never hands out a number twice, and the new ticket would get 4, as the example's second table did.

  2. Question 2 of 2 In PostgreSQL, the customer ids of a table go 1, 2, 3, 7. What may you conclude about 4, 5 and 6?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing about lost rows, because rolled-back and failed inserts also use up numbers

    Generated keys promise only to be unique. PostgreSQL's sequences and MySQL's counters only move forward, so an INSERT that fails or is rolled back leaves a gap. Treat a gap as normal, and never as a sign that a row went missing.

PostgreSQL, MySQL and SQLite differ in the details

Every statement of this lesson runs in all three engines, but several details are spelt differently. Read across a row to see where one engine’s habit breaks in another:

INSERT in PostgreSQL, MySQL and SQLite
Criterion PostgreSQL 18MySQL 8.4SQLite 3.49
Several rows in one VALUES list YesYesYes
INSERT … SELECT YesYesYes
The keyword DEFAULT in a VALUES list YesYesNo: a syntax error
A row of defaults only INSERT … DEFAULT VALUESINSERT … () VALUES ()INSERT … DEFAULT VALUES
New rows returned by the INSERT RETURNINGNo: RETURNING is a syntax errorRETURNING, from 3.35
Last generated key of the connection lastval(), or currval() for one sequenceLAST_INSERT_ID(): the first row of the last INSERTlast_insert_rowid()
Rows added by the last statement The command tag, such as INSERT 0 3ROW_COUNT()changes()
A key given by hand Refused for GENERATED ALWAYS, unless OVERRIDING SYSTEM VALUEAcceptedAccepted
One bad row in a multi-row INSERT Nothing insertedNothing inserted (InnoDB)Nothing inserted

The forms that behave the same everywhere

Name the columns, leave a column out to get its default instead of writing DEFAULT, and read the key with RETURNING in PostgreSQL and SQLite and with LAST_INSERT_ID() in MySQL.

Text that is too long for its column, or a word in a number column, follows the type rules of the lessons on data types: PostgreSQL refuses it, MySQL refuses it in its default strict mode (errors 1406 and 1366), and an ordinary SQLite table stores it as it came, unless the table is declared STRICT.

The two programs below run statements of this lesson on real servers, PostgreSQL 18.6 and MySQL 8.4.11, each started for the run on this computer and deleted after it. The first is PostgreSQL’s:

INSERT on PostgreSQL 18.6: identity keys, DEFAULT and the gaps

pg/insert_postgres.py

"""PostgreSQL 18.6: RETURNING with an identity key, the keyword DEFAULT, a key given by hand, and the gaps that a
rolled-back and a failed INSERT leave in the numbers."""

from pg_server import psql

psql(r"""
CREATE TABLE customers (
  customer_id int  GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        text NOT NULL,
  city        text NOT NULL,
  plan        text NOT NULL DEFAULT 'basic'
);

\echo '-- 1. RETURNING sends back the keys the identity column made, and the default plan'
INSERT INTO customers (name, city) VALUES ('Asha Kulkarni', 'Pune'), ('Vikram Nair', 'Kochi')
RETURNING customer_id, name, plan;

\echo '-- 2. The keyword DEFAULT asks for a column default by name'
INSERT INTO customers (name, city, plan) VALUES ('Farhan Qureshi', 'Lucknow', DEFAULT)
RETURNING customer_id, plan;

\echo '-- 3. GENERATED ALWAYS refuses a key given by hand'
INSERT INTO customers (customer_id, name, city) VALUES (10, 'Meera Rathore', 'Jaipur');

\echo '-- 4. A rolled-back INSERT, then a failed one'
BEGIN;
INSERT INTO customers (name, city) VALUES ('Tenzin Dorjee', 'Gangtok') RETURNING customer_id;
ROLLBACK;
INSERT INTO customers (name, city) VALUES ('Lalremruati', 'Aizawl'), ('Gurpreet Kaur', NULL);

\echo '-- 5. The next INSERT, and the keys in the table'
INSERT INTO customers (name, city) VALUES ('Lalremruati', 'Aizawl') RETURNING customer_id;
SELECT customer_id, name FROM customers ORDER BY customer_id;
""")

Output

-- 1. RETURNING sends back the keys the identity column made, and the default plan
 customer_id |     name      | plan  
-------------+---------------+-------
           1 | Asha Kulkarni | basic
           2 | Vikram Nair   | basic
(2 rows)

-- 2. The keyword DEFAULT asks for a column default by name
 customer_id | plan  
-------------+-------
           3 | basic
(1 row)

-- 3. GENERATED ALWAYS refuses a key given by hand
ERROR:  cannot insert a non-DEFAULT value into column "customer_id"
DETAIL:  Column "customer_id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.
-- 4. A rolled-back INSERT, then a failed one
 customer_id 
-------------
           4
(1 row)

ERROR:  null value in column "city" of relation "customers" violates not-null constraint
DETAIL:  Failing row contains (6, Gurpreet Kaur, null, basic).
-- 5. The next INSERT, and the keys in the table
 customer_id 
-------------
           7
(1 row)

 customer_id |      name      
-------------+----------------
           1 | Asha Kulkarni
           2 | Vikram Nair
           3 | Farhan Qureshi
           7 | Lalremruati
(4 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 insert_postgres.py

pg/pg_server.py

"""Runs SQL on a private, throwaway PostgreSQL 18.6 server and prints what psql printed.

Each run makes a new database cluster in its own temporary folder (initdb), starts a server that listens only on the
loopback address 127.0.0.1, on a free port chosen for this run, with a password made up for this run (scram-sha-256;
the password is never printed), sends the SQL to psql, then stops the server and deletes the folder. Every run
therefore starts from an empty database and prints the same text. (The server opens no Unix socket: the temporary
folders of these runs are too deep for macOS's 103-byte limit on socket paths.)

The settings keep the output the same on every run: no autovacuum (statistics change only when the SQL runs ANALYZE
or VACUUM), statistics taken from every row instead of a random sample (default_statistics_target = 10000), no
parallel workers, no JIT compilation, and WAL buffers large enough never to fill up during a run. The cluster uses the
C locale, UTF-8 and UTC.

PostgreSQL 18.6 comes from the folder where mise installs it (mise install postgres@18.6). That folder is in the home
folder, which the isolated runs of learn:verify may not read, so the examples that use this file are recorded with its
network isolation off (LEARN_VERIFY_NETWORK=allow); the server accepts connections from this computer only. The
output is psql's own, except that the "psql:<stdin>:<line>:" in front of each message is left out and the run's port
number is printed as "<port>". Each call of psql() is a new psql process, so a new connection, on the same server.
"""

import atexit
import os
import pwd
import re
import secrets
import shutil
import socket
import subprocess
import sys

BIN = os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".local", "share", "mise", "installs", "postgres", "18.6", "bin")
WHERE = re.compile(r"^psql:<stdin>:\d+: ", re.MULTILINE)

_base = os.path.join(os.path.normpath(os.environ.get("TMPDIR", os.getcwd())), "pg")
_data = os.path.join(_base, "d")
_running = False
_port = ""
_env: dict[str, str] = {}


def _free_port() -> str:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return str(s.getsockname()[1])


def _quiet(*argv: str) -> None:
    subprocess.run(argv, env=_env, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def _start() -> None:
    global _running, _port, _env
    shutil.rmtree(_base, ignore_errors=True)
    os.makedirs(_base)
    password = secrets.token_urlsafe(18)
    pwfile = os.path.join(_base, "pw")
    with open(pwfile, "w") as f:
        f.write(password + "\n")
    _port = _free_port()
    _env = dict(os.environ, PGHOST="127.0.0.1", PGPORT=_port, PGUSER="postgres", PGDATABASE="postgres",
                PGPASSWORD=password, LC_ALL="C", LANG="C", TZ="UTC")
    _quiet(os.path.join(BIN, "initdb"), "-D", _data, "-U", "postgres", "-A", "scram-sha-256", f"--pwfile={pwfile}",
           "-E", "UTF8", "--locale=C", "--no-sync", "--no-instructions")
    os.remove(pwfile)
    options = (
        f"-c listen_addresses=127.0.0.1 -c port={_port} -c unix_socket_directories='' -c fsync=off -c timezone=UTC"
        " -c autovacuum=off -c default_statistics_target=10000 -c max_parallel_workers_per_gather=0 -c jit=off"
        " -c wal_buffers=64MB"
    )
    _quiet(os.path.join(BIN, "pg_ctl"), "start", "-w", "-s", "-D", _data, "-l", os.path.join(_base, "server.log"), "-o", options)
    _running = True
    atexit.register(_stop)


def _stop() -> None:
    global _running
    if _running:
        subprocess.run([os.path.join(BIN, "pg_ctl"), "stop", "-s", "-m", "fast", "-D", _data], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        _running = False
    shutil.rmtree(_base, ignore_errors=True)


def _clean(text: str) -> str:
    return WHERE.sub("", text).replace(f"port {_port}", "port <port>")


def psql(sql: str) -> None:
    """Sends a block of SQL to psql and prints what it printed, errors included, in order."""
    if not _running:
        _start()
    run = subprocess.run(
        [os.path.join(BIN, "psql"), "-X", "-q", "-v", "ON_ERROR_STOP=0", "-P", "pager=off"],
        input=sql.strip() + "\n", env=_env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
    )
    sys.stdout.write(_clean(run.stdout))
    sys.stdout.flush()

The identity column refused a key given by hand and named the way around it, OVERRIDING SYSTEM VALUE. The rolled-back insert took 4, the failed two-row insert took 5 and 6, and Lalremruati, inserted next, got 7: three numbers that no row will ever have. MySQL’s run follows:

INSERT on MySQL 8.4.11: LAST_INSERT_ID() and INSERT IGNORE

mysql/insert_mysql.py

"""MySQL 8.4.11: no RETURNING, LAST_INSERT_ID() after a two-row INSERT, the keys a failed INSERT uses up, what
INSERT IGNORE keeps, and a row of defaults."""

from mysql_server import mysql

print("-- 1. RETURNING is a syntax error in MySQL")
mysql("""
CREATE TABLE customers (
  customer_id INT         AUTO_INCREMENT PRIMARY KEY,
  name        VARCHAR(40) NOT NULL,
  city        VARCHAR(40) NOT NULL,
  plan        VARCHAR(10) NOT NULL DEFAULT 'basic'
);
INSERT INTO customers (name, city) VALUES ('Asha Kulkarni', 'Pune') RETURNING customer_id;
""")
print("-- 2. Two rows in one INSERT: LAST_INSERT_ID() is the key of the first one")
mysql("""
INSERT INTO customers (name, city) VALUES ('Asha Kulkarni', 'Pune'), ('Vikram Nair', 'Kochi');
SELECT LAST_INSERT_ID(), ROW_COUNT();
""")
print("-- 3. One NULL city: nothing is inserted, yet two keys are used up")
mysql("""
INSERT INTO customers (name, city) VALUES ('Meera Rathore', 'Jaipur'), ('Tenzin Dorjee', NULL);
SELECT count(*) FROM customers;
""")
print("-- 4. INSERT IGNORE keeps the broken row, with '' in place of NULL, and a warning")
mysql("""
INSERT IGNORE INTO customers (name, city) VALUES ('Meera Rathore', 'Jaipur'), ('Tenzin Dorjee', NULL);
SHOW WARNINGS;
SELECT customer_id, name, city, plan FROM customers ORDER BY customer_id;
""")
print("-- 5. A row of defaults: DEFAULT VALUES is a syntax error, () VALUES () works")
mysql("""
CREATE TABLE visits (
  visit_id INT         AUTO_INCREMENT PRIMARY KEY,
  page     VARCHAR(40) NOT NULL DEFAULT '/',
  seconds  INT         NOT NULL DEFAULT 0
);
INSERT INTO visits DEFAULT VALUES;
INSERT INTO visits () VALUES ();
SELECT * FROM visits;
""")

Output

-- 1. RETURNING is a syntax error in MySQL
ERROR 1064 (42000) at line 7: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'RETURNING customer_id' at line 1
-- 2. Two rows in one INSERT: LAST_INSERT_ID() is the key of the first one
+------------------+-------------+
| LAST_INSERT_ID() | ROW_COUNT() |
+------------------+-------------+
|                1 |           2 |
+------------------+-------------+
-- 3. One NULL city: nothing is inserted, yet two keys are used up
ERROR 1048 (23000) at line 1: Column 'city' cannot be null
+----------+
| count(*) |
+----------+
|        2 |
+----------+
-- 4. INSERT IGNORE keeps the broken row, with '' in place of NULL, and a warning
+---------+------+------------------------------+
| Level   | Code | Message                      |
+---------+------+------------------------------+
| Warning | 1048 | Column 'city' cannot be null |
+---------+------+------------------------------+
+-------------+---------------+--------+-------+
| customer_id | name          | city   | plan  |
+-------------+---------------+--------+-------+
|           1 | Asha Kulkarni | Pune   | basic |
|           2 | Vikram Nair   | Kochi  | basic |
|           5 | Meera Rathore | Jaipur | basic |
|           6 | Tenzin Dorjee |        | basic |
+-------------+---------------+--------+-------+
-- 5. A row of defaults: DEFAULT VALUES is a syntax error, () VALUES () works
ERROR 1064 (42000) at line 6: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'DEFAULT VALUES' at line 1
+----------+------+---------+
| visit_id | page | seconds |
+----------+------+---------+
|        1 | /    |       0 |
+----------+------+---------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 insert_mysql.py

mysql/mysql_server.py

"""Runs SQL on a private, throwaway MySQL 8.4.11 server and prints what the mysql client printed.

Each run makes a new data folder in its own temporary folder, starts a server that listens only on the loopback
address 127.0.0.1, on a free port chosen for this run, sets a root password made up for this run (it is never
printed), creates the database "shop", sends the SQL to the mysql client, then shuts the server down and deletes the
folder. Every run starts from an empty database. (The server's Unix socket is named relative to its data folder and
used by no client: the temporary folders of these runs are too deep for macOS's limit on socket paths.)

Two settings keep the plans the same on every run: InnoDB recomputes statistics only when the SQL runs ANALYZE TABLE
(innodb_stats_auto_recalc=OFF), and ANALYZE TABLE reads every page instead of a random sample
(innodb_stats_persistent_sample_pages). The server's clock is UTC.

Every call of mysql() starts a new client, so a new connection, as root in the database "shop" or in none. The
client's warning about a password on its command line is left out of the output.

MySQL 8.4.11 comes from the folder where mise installs it (mise install mysql@8.4.11). That folder is in the home
folder, which the isolated runs of learn:verify may not read, so the examples that use this file are recorded with its
network isolation off (LEARN_VERIFY_NETWORK=allow); the server accepts connections from this computer only.
"""

import atexit
import os
import pwd
import secrets
import shutil
import socket
import subprocess
import sys
import time

HOME = os.path.join(pwd.getpwuid(os.getuid()).pw_dir, ".local", "share", "mise", "installs", "mysql", "8.4.11")
BIN = os.path.join(HOME, "bin")
WARNING = "mysql: [Warning] Using a password on the command line interface can be insecure.\n"

_base = os.path.join(os.environ.get("TMPDIR", os.getcwd()), "my")
_data = os.path.join(_base, "d")
_server = None
_port = ""
_password = secrets.token_urlsafe(18)


def _free_port() -> str:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return str(s.getsockname()[1])


def _client(*args: str, sql: str = "", password: str | None = None) -> subprocess.CompletedProcess:
    login = ["-uroot"] + ([f"--password={password}"] if password is not None else [])
    return subprocess.run(
        [os.path.join(BIN, "mysql"), "--no-defaults", "--protocol=TCP", "--host=127.0.0.1", f"--port={_port}", *login, *args],
        input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
    )


def _start() -> None:
    global _server, _port
    shutil.rmtree(_base, ignore_errors=True)
    os.makedirs(_base)
    _port = _free_port()
    init = os.path.join(_base, "init.sql")
    with open(init, "w") as f:
        f.write(f"CREATE USER 'root'@'127.0.0.1' IDENTIFIED BY '{_password}';\n"
                "GRANT ALL ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;\n"
                f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{_password}';\n"
                "CREATE DATABASE shop;\n")
    server = [
        os.path.join(BIN, "mysqld"), "--no-defaults", f"--basedir={HOME}", f"--datadir={_data}",
        "--socket=m.sock", "--bind-address=127.0.0.1", f"--port={_port}", "--mysqlx=OFF",
        f"--pid-file={os.path.join(_base, 'pid')}", f"--log-error={os.path.join(_base, 'error.log')}", "--skip-log-bin",
        "--performance-schema=OFF", "--innodb-stats-auto-recalc=OFF", "--innodb-stats-persistent-sample-pages=100000",
        "--default-time-zone=+00:00", "--skip-name-resolve",
    ]
    subprocess.run(server[:4] + ["--initialize-insecure", f"--log-error={os.path.join(_base, 'init.log')}"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    _server = subprocess.Popen(server + [f"--init-file={init}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    atexit.register(_stop)
    for _ in range(600):
        if _client("-e", "SELECT 1", password=_password).returncode == 0:
            os.remove(init)
            return
        time.sleep(0.1)
    raise RuntimeError("the MySQL server did not start")


def _stop() -> None:
    global _server
    if _server is not None:
        subprocess.run([os.path.join(BIN, "mysqladmin"), "--no-defaults", "--protocol=TCP", "--host=127.0.0.1", f"--port={_port}", "-uroot", f"--password={_password}", "shutdown"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        try:
            _server.wait(timeout=60)
        except subprocess.TimeoutExpired:
            _server.kill()
        _server = None
    shutil.rmtree(_base, ignore_errors=True)


def mysql(sql: str, database: str | None = "shop") -> None:
    """Sends a block of SQL to the mysql client as root, in `database` (tables as boxes), and prints its output."""
    if _server is None:
        _start()
    run = _client("--table", "--force", "--comments", *([database] if database else []), sql=sql.strip() + "\n", password=_password)
    sys.stdout.write(run.stdout.replace(WARNING, ""))
    sys.stdout.flush()

MySQL answered RETURNING and DEFAULT VALUES with error 1064, a syntax error. After the two-row insert, LAST_INSERT_ID() returned 1, the key of the first row, and ROW_COUNT() 2. The failed insert used up 3 and 4, and INSERT IGNORE then kept Tenzin Dorjee with an empty city and warning 1048, where SQLite’s INSERT OR IGNORE would have skipped the row.

SQLite Online (SQL Playground) Create a table and try INSERT … RETURNING, DEFAULT VALUES and INSERT … SELECT on SQLite in your browser. CSV to SQL Converter Turn a CSV file into a CREATE TABLE and multi-row INSERT statements for PostgreSQL, MySQL or SQLite.

Checkpoint

Check: INSERT across the three engines

  1. Question 1 of 2 Code written for PostgreSQL runs INSERT INTO visits DEFAULT VALUES RETURNING visit_id. What must change for it to run on MySQL 8.4?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both parts, to INSERT INTO visits () VALUES () and then SELECT LAST_INSERT_ID() on the same connection

    MySQL writes a row of defaults as an empty column list and an empty list of values, INSERT INTO visits () VALUES (), and has no RETURNING: SELECT LAST_INSERT_ID() on the same connection returns the key of its last INSERT.

  2. Question 2 of 2 price_paise is declared INTEGER in PostgreSQL, in MySQL 8.4 and in an ordinary SQLite table. An INSERT gives it the text 'abc'. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: PostgreSQL refuses the row, MySQL refuses it in its default strict mode, and the SQLite table stores 'abc' as text

    Each engine applies its own type rules. PostgreSQL refuses a value that does not fit the column's type; MySQL refuses it in its default strict mode (errors 1406 for text that is too long and 1366 for a wrong value); an ordinary SQLite table stores it as it came, unless the table is declared STRICT.

Practise: add products and read back their keys

The exercise adds three products with one statement and returns their new keys. Its sample tests run in your browser, on tables with a gap in their numbers and with an extra column that has a default:

Exercise · Easy · SQL

Add three products and read back their new keys

mart is adding three products to its table products. Each has a name, a category and a price in paise:

  • 'Jowar flour 1 kg', 'Grains', 7200;
  • 'Moong dal 500 g', 'Pulses', 9400;
  • 'Filter coffee 200 g', 'Beverages', 18500.

Add all three with one INSERT statement. Do not give a product_id: it is an INTEGER PRIMARY KEY, so SQLite chooses each new product's number. Return, for each new row, three columns: product_id, name and price_paise. The order of the rows does not matter.

The tests run your statement on different versions of the table: one has a gap in its numbers, and one has an extra column with a default value, which your INSERT should leave to the database.

Starter code · new_products.sql

-- Add the three new products in one INSERT statement, and let SQLite choose each
-- product_id. Return product_id, name and price_paise of the new rows.
SELECT product_id, name, price_paise FROM products WHERE product_id IS NULL;
The sample tests · tests.yaml
# Sample tests: each one builds a products table, runs your statement on it and compares the rows it returns (in any
# order).
tests:
  - name: returns the three new products with the numbers SQLite chose
    setup: |
      CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
      INSERT INTO products (product_id, name, category, price_paise) VALUES
        (1, 'Basmati rice 1 kg', 'Grains', 14999),
        (2, 'Toor dal 500 g', 'Pulses', 8950),
        (3, 'Masala chai 250 g', 'Beverages', 12000),
        (4, 'Coconut oil 1 L', 'Oils', 21500),
        (5, 'Ragi flour 1 kg', 'Grains', 6800);
    expected:
      columns: [product_id, name, price_paise]
      rows:
        - [6, 'Jowar flour 1 kg', 7200]
        - [7, 'Moong dal 500 g', 9400]
        - [8, 'Filter coffee 200 g', 18500]
  - name: continues after the largest number, even when there is a gap
    setup: |
      CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
      INSERT INTO products (product_id, name, category, price_paise) VALUES
        (1, 'Basmati rice 1 kg', 'Grains', 14999),
        (2, 'Toor dal 500 g', 'Pulses', 8950),
        (40, 'Jaggery 1 kg', 'Sweeteners', 9900);
    expected:
      columns: [product_id, name, price_paise]
      rows:
        - [41, 'Jowar flour 1 kg', 7200]
        - [42, 'Moong dal 500 g', 9400]
        - [43, 'Filter coffee 200 g', 18500]
  - name: leaves a column it does not mention to its default
    setup: |
      CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, in_stock INTEGER NOT NULL DEFAULT 1, category TEXT NOT NULL, price_paise INTEGER NOT NULL);
      INSERT INTO products (product_id, name, in_stock, category, price_paise) VALUES
        (1, 'Basmati rice 1 kg', 1, 'Grains', 14999),
        (2, 'Toor dal 500 g', 0, 'Pulses', 8950);
    expected:
      columns: [product_id, name, price_paise]
      rows:
        - [3, 'Jowar flour 1 kg', 7200]
        - [4, 'Moong dal 500 g', 9400]
        - [5, 'Filter coffee 200 g', 18500]
A hint

Name the three columns you give values for, (name, category, price_paise), after the table name, then write one list of values per product after VALUES, separated by commas. End the statement with RETURNING and the three columns to return.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

The questions below cover the seven ideas of this lesson, from the column list to the engines, and each round begins with the idea you have answered worst so far:

Practice that adapts to you

Practise this lesson

18 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 Why is one INSERT with three lists of values faster than three INSERT statements of one row each?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: The database parses one statement, and the program waits for one answer instead of three

    A multi-row VALUES list makes one statement: the database parses it once and the program waits for one answer. The rows are still inserted and checked one by one, and the statement succeeds or fails as a whole.

  2. Question 2 of 18 customers has five columns: customer_id, name, city, plan and credit_paise. Which INSERT keeps working after a migration adds a sixth column with a default?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: INSERT INTO customers (customer_id, name, city) VALUES (8, 'Riya Sen', 'Siliguri')

    A column list makes an INSERT independent of the columns it does not name and of their order. A new column with a default is simply left out and filled by the database.

  3. Question 3 of 18 After a rebuild, customers_v2 defines city before name, both TEXT. Which INSERT stores Meera Rathore as the name?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: INSERT INTO customers_v2 (customer_id, name, city) VALUES (3, 'Meera Rathore', 'Jaipur')

    Both columns are text, so the short form fails silently: it pairs value 2 with column 2, which is now city. The column list removes the dependence on the table's order.

  4. Question 4 of 18 An INSERT adds three rows with one VALUES list. The third row breaks a NOT NULL rule. How many of the three rows are in the table afterwards, in PostgreSQL, MySQL (InnoDB) or SQLite?

    Type a number.

    Show the answer: Question 4 of 18

    Answer: 0 rows

    A statement succeeds or fails as a whole. The error on the third row takes back the first two as well, so the table is as it was before the statement. To keep the good rows, a load has to ask for it, or check the rows before inserting them.

  5. Question 5 of 18 In MySQL 8.4, INSERT IGNORE INTO customers (name, city) VALUES ('Meera Rathore', 'Jaipur'), ('Tenzin Dorjee', NULL) runs, and city is NOT NULL. What does the table hold afterwards?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: Both rows, Tenzin Dorjee with an empty city, and the statement reports warning 1048

    MySQL's INSERT IGNORE does not skip a row that has NULL in a NOT NULL column: it stores the implicit default of the column's type and leaves warning 1048, as the MySQL run of this lesson showed. The row is kept with wrong data, which is why a load checks its rows instead.

  6. Question 6 of 18 seconds is declared INTEGER NOT NULL DEFAULT 0. What does INSERT INTO visits (page, seconds) VALUES ('/about/', NULL) do?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: It fails, because a NOT NULL column refuses an explicit NULL, default or not

    SQLite reported "NOT NULL constraint failed: visits.seconds". To get the default, leave seconds out of the column list.

  7. Question 7 of 18 Which statement inserts a visit to '/blog/' with device left to its default, in SQLite?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: INSERT INTO visits (page) VALUES ('/blog/')

    In SQLite, the way to get a column's default is to leave the column out. PostgreSQL and MySQL also accept the keyword DEFAULT in a VALUES list.

  8. Question 8 of 18 A table has stored 10,000 rows with plan NULL. A migration now gives plan the default 'basic'. What happens to the 10,000 rows?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: Nothing, because a default is used only when a row is inserted

    A default is used for new rows that leave the column out; it never changes rows already stored. Existing NULLs stay until an UPDATE changes them, for example UPDATE customers SET plan = 'basic' WHERE plan IS NULL.

  9. Question 9 of 18 How does the monthly copy give every copied row of price_history the same month?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: The month is written as a constant in the SELECT list, so every row of the query carries it

    INSERT with SELECT inserts what the query returns, row by row. A constant in the SELECT list, like the month in the example, gives every copied row the same value.

  10. Question 10 of 18 A SELECT returns the columns name, price_paise and product_id, in that order. Which column list puts each value in the right column?

    Choose one answer.

    Show the answer: Question 10 of 18

    Answer: INSERT INTO archive (name, price_paise, product_id) SELECT name, price_paise, product_id FROM products

    Write the column list and the SELECT list in the same order. The engine pairs them by position, never by name.

  11. Question 11 of 18 In SQLite, a colleague copies products with CREATE TABLE products_copy AS SELECT * FROM products. What does products_copy lack?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: The keys and constraints that the CREATE TABLE of products declared

    CREATE TABLE … AS SELECT makes a new table from a query, with the query's columns. INSERT with SELECT, by contrast, needs a table that exists, with the keys and constraints its CREATE TABLE declared.

  12. Question 12 of 18 In SQLite, INSERT INTO customers (name, city) VALUES ('Lalremruati', 'Aizawl'), ('Gurpreet Kaur', 'Amritsar') RETURNING customer_id, name runs. How many rows does it return?

    Type a number.

    Show the answer: Question 12 of 18

    Answer: 2 rows

    RETURNING returns one row per inserted row, with the columns it names. The example's second INSERT returned 4 for Lalremruati and 5 for Gurpreet Kaur.

  13. Question 13 of 18 In which of these engines does INSERT … RETURNING customer_id work?

    Choose every answer that is right.

    Show the answer: Question 13 of 18

    Answer:

    • PostgreSQL 18
    • SQLite 3.49

    PostgreSQL and SQLite return the new rows from the INSERT itself. MySQL has no RETURNING, so the same connection runs SELECT LAST_INSERT_ID() right after the INSERT.

  14. Question 14 of 18 An order service inserts an order and then its three items, which need the order's key. In PostgreSQL, which way of writing it can never put the items on someone else's order?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: INSERT the order with RETURNING order_id, then insert the items with the key it returned

    Take the key from the INSERT itself: RETURNING in PostgreSQL and SQLite, LAST_INSERT_ID() on the same connection in MySQL. Any second look at the whole table can see other requests' rows.

  15. Question 15 of 18 What does SQLite's AUTOINCREMENT add to an INTEGER PRIMARY KEY?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: A promise never to hand out a number twice, even after the largest row is deleted

    Without AUTOINCREMENT, SQLite gives a new row the largest key in the table plus one, so a deleted largest key can come back. With AUTOINCREMENT it remembers the largest number ever used and never hands it out again.

  16. Question 16 of 18 Customers receive links such as /tickets/3 by email. Tickets are deleted when they are resolved. Which key keeps an old link from opening someone else's ticket?
    Show the answer: Question 16 of 18

    Answer: An AUTOINCREMENT key in SQLite, or a sequence or AUTO_INCREMENT key in PostgreSQL and MySQL

    A key that leaves the database in a link must never be reused. Keys that only move forward leave gaps, which is harmless; a reused number sends an old link to a new row.

  17. Question 17 of 18 Which statement inserts a row made only of default values in MySQL 8.4?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: INSERT INTO visits () VALUES ()

    MySQL writes an empty column list and an empty VALUES list. INSERT … DEFAULT VALUES is the spelling of PostgreSQL and SQLite.

  18. Question 18 of 18 In PostgreSQL 18, customer_id is GENERATED ALWAYS AS IDENTITY. What does INSERT INTO customers (customer_id, name, city) VALUES (10, 'Meera Rathore', 'Jaipur') do?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: It fails, and the hint names OVERRIDING SYSTEM VALUE as the way to give a key by hand

    PostgreSQL answered "cannot insert a non-DEFAULT value into column customer_id" with the hint "Use OVERRIDING SYSTEM VALUE to override". GENERATED BY DEFAULT AS IDENTITY would accept the key given.

Interview questions

Fresher · What is the difference between leaving a column out of an INSERT and inserting NULL into it?

Leaving the column out of the column list lets the database fill it: with the column’s DEFAULT when it has one, otherwise with NULL. Writing NULL is giving a value, so the default is not used, and a NOT NULL column refuses the row with an error, default or not. In PostgreSQL and MySQL the keyword DEFAULT in a VALUES list asks for the default explicitly; SQLite has no such keyword, so there you leave the column out. The difference matters for programs that always send every column: an ORM or a form handler that turns empty fields into NULL never gets the defaults, so plan stays NULL instead of 'basic', or the insert fails. A default also applies only when a row is inserted; it never fills in NULLs that are already stored.

Fresher · How do you get the id of a row you have just inserted, in PostgreSQL, MySQL and SQLite?

In PostgreSQL, add RETURNING id to the INSERT: the statement returns the new rows, one per inserted row, with any columns you name. SQLite supports the same RETURNING clause from version 3.35, and also has last_insert_rowid() for the last row its connection inserted. MySQL has no RETURNING, so you call SELECT LAST_INSERT_ID() on the same connection right after the INSERT; the value belongs to that connection, so other sessions inserting at the same time cannot change it, and after a multi-row INSERT it is the key of the first row. Never use SELECT max(id): another connection can insert between your two statements, and you would read its id instead of yours.

Two longer questions of this module start from this lesson:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    An order service inserts an order, reads its id with SELECT max(order_id) FROM orders, then inserts the order's items with that id. Every test passes, but in production some items end up on other customers' orders. Explain why, and how you would write it in PostgreSQL and in MySQL.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    On MySQL 8.4, one INSERT adds 500 rows to a table with an AUTO_INCREMENT key, and the application needs all 500 new keys to insert child rows. LAST_INSERT_ID() returns 10001. May the code assume the keys are 10001 to 10500? How do you get them reliably?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • INSERT INTO t (a, b) VALUES (…), (…) adds one row per list of values, matched to the column list by position, so write the column list in every INSERT that lives in code.
  • A multi-row INSERT succeeds or fails as a whole, and the ways to keep the good rows differ: SQLite’s INSERT OR IGNORE skips a broken row, while MySQL’s INSERT IGNORE keeps a NULL-breaking row with a default.
  • A column left out of the list gets its DEFAULT, or NULL, while an explicit NULL is a value that replaces the default.
  • INSERT … SELECT copies a query’s rows into the column list by position, and a constant in the SELECT list gives every copied row the same value.
  • A new row’s key comes from the INSERT itself, with RETURNING in PostgreSQL and SQLite or LAST_INSERT_ID() in MySQL, which reports the first key of a multi-row insert, and never from max(id).
  • Keys are unique, not consecutive: failed and rolled-back inserts leave gaps, and SQLite without AUTOINCREMENT can give a deleted largest key out again.

Terms in this lesson

column default
The value a column's DEFAULT clause gives a new row when the INSERT leaves that column out of its column list. An explicit NULL is a value, so it never brings the default in.
generated key
A key the database chooses for a new row, from an identity column or sequence, an AUTO_INCREMENT counter or SQLite's rowid. It is unique, but it is not a count of rows and it can have gaps.
RETURNING
A clause that makes an INSERT, UPDATE or DELETE return the rows it changed, with the columns it names, in the same round trip. PostgreSQL and SQLite have it; MySQL does not.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.