You've got somewhere to put the data. Now it's time to put it there. INSERT is the statement that adds rows to a table and, on the face of it, it's the simplest one in the module: table name, columns, values, done. But behind that simplicity there are decisions separating a script that survives five years from one that breaks at the first refactoring: listing or not listing the columns, inserting one row or forty in a single statement, letting the engine generate the id or forcing it (and what silent disaster the latter causes), and how to retrieve the freshly created id so you can insert the lines of the order you've just recorded.
In this lesson you'll learn the five forms of INSERT, you'll finally understand what the setval block that ends greenstore.sql does, you'll be able to diagnose at a glance each of the four errors an INSERT can give you, and you'll finish by recording a complete order with its three lines using RETURNING, which is exactly what an online shop does every time somebody clicks "Confirm purchase".
Contents
- The syntax, and why you should always list the columns
- Inserting several rows in a single statement
DEFAULTandDEFAULT VALUES- The identity column: omitting it, forcing it and
setval RETURNING: retrieving what you've just insertedINSERT ... SELECT: inserting the result of a query- The four errors of an
INSERTand how to diagnose them - The order of insertion when there are foreign keys
ON CONFLICT DO NOTHING, in passing- Bulk loading:
COPYand\copyagainstINSERT - A complete example: adding a product and recording an order
- Common Mistakes and Tips
- Exercises
- Conclusion
- The syntax, and why you should always list the columns
Adding a supplier:
INSERT INTO suppliers (name, country, email, active)
VALUES ('Cooperativa La Safor', 'Spain', 'ventas@lasafor.es', TRUE);That psql output deserves an explanation, because it throws everybody the first time:
| Part | Meaning |
|---|---|
INSERT |
The kind of statement |
0 |
The OID of the inserted row. It's a relic: today it's always 0 |
1 |
The number of rows inserted. This is the number that matters |
Every time you run an INSERT, look at the second number. If you were expecting 5 rows and it says INSERT 0 3, something's wrong.
The form without columns, and why you shouldn't use it
SQL lets you omit the column list if you give a value for all of them, in the table's exact order:
-- ⚠️ INCORRECT as a practice, even though it works today
INSERT INTO suppliers
VALUES (7, 'Cooperativa La Safor', 'Spain', 'ventas@lasafor.es', TRUE);It works. And it's a time bomb, for four reasons:
| Problem | What happens |
|---|---|
| You depend on the physical order | If somebody reorders the columns with an ALTER TABLE, your INSERTs start putting the country into the email |
| It breaks when columns are added | The day suppliers gains a phone column, all these INSERTs will fail with INSERT has more target columns than expressions… or worse, they'll keep working and shift the data across |
| Unreadable | ('Spain', 'ventas@…', TRUE) doesn't tell you what's what. With fifteen columns it's downright undecipherable |
It forces you to give the id |
By listing every column you include the identity one, and that's where section 4's problem starts |
The rule, with no exceptions: always list the columns. It costs twenty characters and saves you an entire class of bugs. It is, by a distance, the most profitable habit in this lesson.
With an explicit list you can also omit the columns that have a DEFAULT or allow nulls, and put them in whatever order you like:
id is generated by the identity, email is left NULL (it's nullable) and active takes its DEFAULT TRUE. Three columns filled in without writing them.
- Inserting several rows in a single statement
Just separate the tuples with commas:
INSERT INTO categories (name, description) VALUES
('Bulk pantry', 'Pulses, grains and nuts with no packaging'),
('Babies', 'Organic-certified baby care'),
('Pets', 'Food and accessories for companion animals');It's the form greenstore.sql uses in its nine loading blocks, and it isn't only a matter of convenience: it's vastly faster.
Why it's faster
An INSERT of N rows doesn't cost the same as N INSERTs of one row:
| Cost | N separate statements | 1 statement with N tuples |
|---|---|---|
| Client↔server network round trips | N | 1 |
| Parsing and planning the query | N times | once |
Implicit transactions (with no BEGIN) |
N commits to disk | 1 |
| Constraint checking | N (unavoidable) | N (unavoidable) |
The first three costs are fixed per statement and usually dominate. In practice, inserting 1,000 rows with a single statement rather than 1,000 separate statements can be between 10 and 50 times faster, and most of that difference comes from the third point: with no explicit transaction, each separate INSERT commits to disk on its own.
Practical limits: don't write a statement with 100,000 tuples. The query text becomes enormous and so does the server's memory consumption. Batches of 500 to 5,000 rows are the usual sweet spot. And if we're talking millions, the tool is no longer INSERT but COPY (section 10).
DEFAULT and DEFAULT VALUES
DEFAULT and DEFAULT VALUESThe keyword DEFAULT can appear as a value, and it means "use this column's default value":
INSERT INTO products (name, category_id, supplier_id, price, cost, stock, active, added_date)
VALUES ('Organic pardina lentils 500 g', 1, 1, 2.45, 1.05, DEFAULT, DEFAULT, DEFAULT);stock takes 0, active takes TRUE and added_date takes CURRENT_DATE. It's identical to having omitted the three columns, and in fact omitting them is preferable: it reads better. DEFAULT as a value is only useful when you build the statement from a program and find it more convenient to keep the column list fixed.
DEFAULT VALUES inserts a row with all the default values:
CREATE TABLE demo_defaults (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
date DATE NOT NULL DEFAULT CURRENT_DATE
);
INSERT INTO demo_defaults DEFAULT VALUES;| id | status | date |
|---|---|---|
| 1 | pending | 2026-03-01 |
(The date will be that of the day you run it.) It's a rare case, but it exists: "identifier reservation" tables or initial-state tables that get filled in later.
- The identity column: omitting it, forcing it and
setval
setvalThis section finally explains the most cryptic block in greenstore.sql.
The normal thing: omit the id
INSERT INTO categories (name, description)
VALUES ('Bulk pantry', 'Pulses, grains and nuts with no packaging');Since you haven't given an id, PostgreSQL asks the associated sequence for the next value. It's the correct thing and what you'll do 99 % of the time.
Forcing it: legal with BY DEFAULT, and dangerous
Since GreenStore declares its identities GENERATED BY DEFAULT, you can give the id by hand:
It works… and it doesn't touch the sequence. The sequence still believes the last value handed out was 6. So the next INSERT without an id:
ERROR: duplicate key value violates unique constraint "categories_pkey" DETAIL: Key (id)=(7) already exists.
The sequence handed out 7, which was already taken. That's the sequence drifting, and it's one of the most bewildering failures there is: the statement that fails is correct and the error points at an id you never wrote.
flowchart TD
A["Sequence at 6<br/>categories has ids 1..6"] --> B["INSERT with an explicit<br/>id = 7"]
B --> C["Table: ids 1..7<br/>Sequence: still at 6 ❌"]
C --> D["INSERT with no id"]
D --> E["The sequence hands out 7"]
E --> F["💥 duplicate key value<br/>violates categories_pkey"]
The fix: setval
setval repositions the sequence. The robust form, which doesn't require knowing what the sequence is called:
| setval |
|---|
| 7 |
Now the next INSERT with no id will ask for 8 and it'll work. And here's the explanation of the course script's nine closing lines:
SELECT setval(pg_get_serial_sequence('categories', 'id'), (SELECT MAX(id) FROM categories));
SELECT setval(pg_get_serial_sequence('suppliers', 'id'), (SELECT MAX(id) FROM suppliers));
SELECT setval(pg_get_serial_sequence('products', 'id'), (SELECT MAX(id) FROM products));
...greenstore.sql inserts every id by hand —so the lessons can talk about "customer 7" or "order 12" with stable, reproducible ids—, so when the load finishes the nine sequences are all at 1. Without that block, the first INSERT with no id on any table would fail with a duplicate key. That's the detail 01-06 left announced "for module 5".
Three useful functions around sequences:
| Function | What it does |
|---|---|
pg_get_serial_sequence('table','column') |
Returns the name of the associated sequence ('public.categories_id_seq') |
currval('sequence') |
The last value handed out in your session. It fails if you haven't asked for one yet |
setval('sequence', n) |
Repositions it: the next value will be n + 1 |
How to avoid all this: declare your identities
GENERATED ALWAYS AS IDENTITY(05-01) and never force them. GreenStore usesBY DEFAULTfor one specific teaching reason; your schema doesn't have to.
And a detail that surprises people: sequences aren't undone by a ROLLBACK. If a transaction consumes the value 21 and then aborts, that 21 is lost for good. It's deliberate —otherwise two concurrent sessions would have to wait for each other— and it means autogenerated ids have gaps. They aren't a row counter and shouldn't be used as one.
RETURNING: retrieving what you've just inserted
RETURNING: retrieving what you've just insertedA plain INSERT only tells you how many rows it put in. RETURNING, a PostgreSQL extension, gives you back the inserted rows, generated values included:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Organic chickpeas 500 g', 1, 1, 2.60, 1.15, 140)
RETURNING id, name, price, stock, active, added_date;| id | name | price | stock | active | added_date |
|---|---|---|---|---|---|
| 21 | Organic chickpeas 500 g | 2.60 | 140 | true | 2026-03-01 |
There you have the id the engine generated (21, the one after 20), the active the DEFAULT put in and the added_date CURRENT_DATE put in. You'd have had to go looking for all of that with a later SELECT… which, moreover, wouldn't be reliable on a system with several users: another order could have come in between your INSERT and your SELECT.
RETURNING accepts the same things a SELECT does: columns, expressions, aliases and *.
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Organic royal quinoa 500 g', 1, 3, 6.80, 3.40, 90)
RETURNING id,
name,
price,
cost,
price - cost AS margin,
ROUND((price - cost) / price, 4) AS relative_margin;| id | name | price | cost | margin | relative_margin |
|---|---|---|---|---|---|
| 22 | Organic royal quinoa 500 g | 6.80 | 3.40 | 3.40 | 0.5000 |
Why RETURNING is indispensable
The canonical case is inserting a parent and its children: an order and its lines. Without RETURNING, the sequence would be:
INSERT INTO orders ...SELECT MAX(id) FROM orders← here's the flawINSERT INTO order_lines (order_id, ...) VALUES (that_id, ...)
Step 2 is a race condition: if another customer confirms their purchase between your step 1 and your step 2, MAX(id) gives you back somebody else's order and you hang your lines off it. In a shop with real traffic this isn't a theoretical possibility: it happens. RETURNING solves it at the root because it returns your row, not the table's last one.
Equivalents by engine
| Engine | How the generated id is retrieved |
|---|---|
| PostgreSQL | INSERT ... RETURNING id (also in UPDATE and DELETE) |
| MySQL / MariaDB | SELECT LAST_INSERT_ID(); after the INSERT, on the same connection |
| SQL Server | SCOPE_IDENTITY(), or the OUTPUT INSERTED.id clause in the statement itself |
| SQLite | SELECT last_insert_rowid(); or RETURNING since version 3.35 |
| Oracle | INSERT ... RETURNING id INTO :variable (in PL/SQL or with an output parameter) |
Two warnings about that table. MySQL's LAST_INSERT_ID() is per connection, so it's safe against other users but it overwrites itself if you do two INSERTs in a row. And in SQL Server you should prefer SCOPE_IDENTITY() to @@IDENTITY: the latter returns the id generated by any scope, triggers included, which is a classic source of errors.
RETURNINGworks the same way inUPDATE(05-03) and inDELETE(05-04), and there it's even more useful: it lets you see exactly what you've changed or what you've deleted.
INSERT ... SELECT: inserting the result of a query
INSERT ... SELECT: inserting the result of a queryInstead of VALUES, an INSERT can be fed by a query. It's pure DML: you aren't nesting a subquery (that's module 7), you're connecting a SELECT's output to an INSERT's input.
The columns are paired by position, not by name. It's the classic trap: if the SELECT returns (name, price) and the target list says (price, name), PostgreSQL will complain about incompatible types… or it won't complain and it'll leave your data crossed over.
Case 1: populating a history table
Before archiving the delivered orders, we take a snapshot:
CREATE TABLE orders_history (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
shipping_cost NUMERIC(10,2) NOT NULL,
archived_date DATE NOT NULL DEFAULT CURRENT_DATE
);INSERT INTO orders_history (id, customer_id, order_date, status, shipping_cost)
SELECT o.id,
o.customer_id,
o.order_date,
o.status,
o.shipping_cost
FROM orders AS o
WHERE o.status = 'delivered';14 rows, the 14 delivered orders. archived_date doesn't appear in the target list, so it takes its DEFAULT CURRENT_DATE in all of them.
Case 2: duplicating a supplier's catalogue
Supplier 5 (EcoNordic Supplies) is inactive. Verde Atlántico (supplier 3) offers to supply the same items with an 8 % surcharge on the cost. Instead of typing four separate entries:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock, active, added_date)
SELECT p.name || ' (Verde Atlántico)',
p.category_id,
3,
p.price,
ROUND(p.cost * 1.08, 2),
0,
TRUE,
DATE '2026-03-01'
FROM products AS p
WHERE p.supplier_id = 5
ORDER BY p.id;SELECT id, name, category_id, supplier_id, price, cost, stock
FROM products
WHERE id > 20
ORDER BY id;| id | name | category_id | supplier_id | price | cost | stock |
|---|---|---|---|---|---|---|
| 21 | Concentrated eco laundry detergent 1 L (Verde Atlántico) | 3 | 3 | 11.20 | 6.48 | 0 |
| 22 | Soy wax candles (pack of 2) (Verde Atlántico) | 3 | 3 | 13.75 | 7.45 | 0 |
| 23 | Bamboo toothbrush (Verde Atlántico) | 5 | 3 | 3.50 | 1.30 | 0 |
| 24 | Spirulina capsules 120 units (Verde Atlántico) | 6 | 3 | 16.40 | 9.40 | 0 |
Four new products with one statement. Look at what the SELECT does: it transforms while it copies. It concatenates a suffix onto the name, replaces supplier_id with a constant, recomputes the cost and sets stock to zero. That's the real value of INSERT ... SELECT: it isn't copying, it's copying while transforming.
Checking the costs, rounded to two decimals: 6.00 × 1.08 = 6.48; 6.90 × 1.08 = 7.452 → 7.45; 1.20 × 1.08 = 1.296 → 1.30; 8.70 × 1.08 = 9.396 → 9.40.
Case 3: INSERT ... SELECT with LIMIT
Everything you know from module 2 applies here, ORDER BY and LIMIT included:
CREATE TEMP TABLE top_products (
position INTEGER,
id INTEGER,
name VARCHAR(150),
price NUMERIC(10,2)
);
INSERT INTO top_products (position, id, name, price)
SELECT ROW_NUMBER() OVER (ORDER BY p.price DESC, p.id),
p.id,
p.name,
p.price
FROM products AS p
ORDER BY p.price DESC, p.id
LIMIT 5;| position | id | name | price |
|---|---|---|---|
| 1 | 15 | Ceremonial matcha green tea 30 g | 22.00 |
| 2 | 6 | Aloe vera face cream 50 ml | 18.90 |
| 3 | 20 | Spirulina capsules 120 units | 16.40 |
| 4 | 8 | Almond body oil 200 ml | 14.25 |
| 5 | 13 | Soy wax candles (pack of 2) | 13.75 |
(ROW_NUMBER() is a window function from module 10; here it just numbers the ranking's rows.)
And watch out for one nuance: an ORDER BY inside an INSERT ... SELECT doesn't guarantee the physical order of the rows in the target table, because in the relational model a table is an unordered set (01-05). It only determines which rows go in when there's a LIMIT. To read them in order you'll always need an ORDER BY in the SELECT.
- The four errors of an
INSERT and how to diagnose them
INSERT and how to diagnose themAn INSERT can fail for four reasons, one per kind of constraint. Knowing the literal messages saves you hours.
7.1. NOT NULL violation
ERROR: null value in column "name" of relation "products" violates not-null constraint DETAIL: Failing row contains (23, null, 1, null, 4.50, null, 0, t, 2026-03-01).
The DETAIL shows you the complete row as it would have ended up, with the default values already applied. It's very useful: there you can see which columns filled themselves in.
7.2. UNIQUE (or PK) violation
ERROR: duplicate key value violates unique constraint "categories_name_key" DETAIL: Key (name)=(Drinks) already exists.
The constraint's name tells you which column. If it were categories_pkey, it'd be the id: probably section 4's sequence drift.
7.3. CHECK violation
INSERT INTO reviews (product_id, customer_id, rating, comment, date)
VALUES (1, 14, 7, 'Great', '2026-03-01');ERROR: new row for relation "reviews" violates check constraint "reviews_rating_check" DETAIL: Failing row contains (13, 1, 14, 7, Great, 2026-03-01).
The constraint's name identifies the rule. Here it's the rating from 1 to 5, and that's why 05-01 insisted so much on naming them: chk_reviews_rating would have been just as clear; reviews_check1 wouldn't have been.
7.4. FOREIGN KEY violation
INSERT INTO orders (customer_id, order_date, status, payment_method, shipping_cost)
VALUES (999, '2026-03-01', 'pending', 'card', 4.95);ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(999) is not present in table "customers".
is not present in table is the unmistakable signature of a broken FK: you've referenced something that doesn't exist.
Quick diagnosis table
| Fragment of the message | Constraint broken | What to look at |
|---|---|---|
violates not-null constraint |
NOT NULL |
A mandatory value is missing; look for it in the DETAIL |
duplicate key value violates unique constraint "..._pkey" |
PRIMARY KEY |
Are you forcing the id? Has the sequence drifted? → setval |
duplicate key value violates unique constraint "..._key" |
UNIQUE |
A row with that value already exists. Did you want an UPSERT? → 05-05 |
violates check constraint |
CHECK |
A value outside the domain or the range. The name tells you which |
violates foreign key constraint + is not present in table |
FOREIGN KEY |
The parent doesn't exist. Insertion order? → section 8 |
violates foreign key constraint + is still referenced from table |
FOREIGN KEY |
This isn't an INSERT: it's a DELETE → 05-04 |
column "x" of relation "y" does not exist |
— | A typo in the column name. \d y |
INSERT has more expressions than target columns |
— | A mismatch between the column list and the value list |
invalid input syntax for type numeric: "12,50" |
— | A decimal comma instead of a point (01-03) |
One important detail: when a multi-row INSERT fails, no row is inserted at all. A statement is atomic: either it all goes in or nothing does. If you insert 500 tuples and number 337 violates a CHECK, the other 499 aren't saved either.
- The order of insertion when there are foreign keys
Referential integrity imposes the same order it imposed on CREATE TABLE in 05-01: parents first, children afterwards.
flowchart TD
A["1 · categories<br/>suppliers"] --> B["2 · products"]
A2["1 · customers<br/>employees"] --> C["3 · orders"]
B --> D["4 · order_lines"]
C --> D
B --> E["4 · reviews"]
A2 --> E
C --> F["4 · returns"]
Try skipping it and you'll see why:
-- ⚠️ INCORRECT: category 9 doesn't exist yet
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Frozen spelt bread', 9, 1, 3.20, 1.60, 40);ERROR: insert or update on table "products" violates foreign key constraint "products_category_id_fkey" DETAIL: Key (category_id)=(9) is not present in table "categories".
The category first, the product afterwards:
-- ✅ CORRECT
INSERT INTO categories (id, name, description)
VALUES (9, 'Bakery', 'Frozen organic bread and pastries');
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Frozen spelt bread', 9, 1, 3.20, 1.60, 40);The reflexive relationships have a nuance of their own. customers.referred_by_id points at the same table, so the referrer has to exist before the referred customer. In the course script it works because the customers are ordered by id and none of them is referred by another with a higher id. If that weren't the case, you'd have two options: insert first with referred_by_id set to NULL and update it afterwards (05-03), or use a deferred constraint (DEFERRABLE INITIALLY DEFERRED), which postpones the check to the end of the transaction. That last one belongs squarely to module 9's transactional model.
ON CONFLICT DO NOTHING, in passing
ON CONFLICT DO NOTHING, in passingIf you try to insert a row that violates a UNIQUE, the INSERT fails. Sometimes what you want is for it not to fail and simply do nothing:
INSERT INTO categories (name, description)
VALUES ('Drinks', 'Attempted duplicate')
ON CONFLICT DO NOTHING;Zero rows inserted, zero errors. It's tremendously useful for scripts that have to be rerunnable (master data loads, development seeds).
Its big sister, ON CONFLICT ... DO UPDATE, solves the classic "insert it if it doesn't exist and update it if it does". That's the UPSERT, and it has a lesson of its own: 05-05.
- Bulk loading:
COPY and \copy against INSERT
COPY and \copy against INSERTFor large volumes, INSERT isn't the tool. PostgreSQL has COPY, designed specifically to move data between a file and a table:
-- COPY runs on the SERVER: the path is the server's
-- and you need to be a superuser or hold the pg_read_server_files role
COPY products (name, category_id, supplier_id, price, cost, stock)
FROM '/var/lib/postgresql/import/products.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');\copy is psql's equivalent command, which reads the file on your machine and sends it over the connection. It needs no special permissions and it's the one you'll use almost always:
greenstore=> \copy products (name, category_id, supplier_id, price, cost, stock) FROM 'products.csv' WITH (FORMAT csv, HEADER true)
It also works the other way round, for exporting:
greenstore=> \copy (SELECT id, name, price FROM products ORDER BY id) TO 'catalog.csv' WITH (FORMAT csv, HEADER true)
An honest comparison:
Multi-row INSERT |
COPY / \copy |
|
|---|---|---|
| Source of the data | SQL text | A CSV/text file or a stream |
| Relative speed | The baseline | 5 to 20 times faster |
| Parsing the statement | Once per statement | None: it's a binary/textual protocol |
| Constraint checking | Yes | Yes, all of them |
ON CONFLICT |
Yes | No |
| Transforming the data | Yes (with INSERT ... SELECT) |
No: it goes in as it comes |
| If one row fails | The whole statement fails | The whole load fails |
| Standard SQL | Yes | No: it's PostgreSQL's own |
The rule: tens or hundreds of rows → a multi-row
INSERT. Thousands or millions →COPY. And if you need to transform while loading, the professional pattern has two steps:COPYinto a staging table with no constraints, and from thereINSERT ... SELECTtransforming into the definitive table. That pattern will come back in 05-05 and in module 11.
Equivalents by engine: MySQL has LOAD DATA INFILE, SQL Server has BULK INSERT and the bcp utility, Oracle has SQL*Loader and external tables, and SQLite has its console's .import command. None of them is compatible with the others.
- A complete example: adding a product and recording an order
We close with what a real shop does. We're working on the freshly loaded database.
11.1. Adding a new product
Huerta del Turia (supplier 1) brings chickpeas into the Food category:
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Organic chickpeas 500 g', 1, 1, 2.60, 1.15, 140)
RETURNING id, name, price, cost, stock, active, added_date;| id | name | price | cost | stock | active | added_date |
|---|---|---|---|---|---|---|
| 21 | Organic chickpeas 500 g | 2.60 | 1.15 | 140 | true | 2026-03-01 |
Three columns omitted and three columns filled in by themselves: id by the identity, active and added_date by their DEFAULTs. Exactly the work 05-01 declared in the DDL.
11.2. Recording an order with its lines
Pau Llorens Vidal (customer 6) rings up. Óscar Peris Blasco (employee 4) takes the call. He wants two olive oils, one honey and three chamomile teas, with €4.95 of shipping.
Step 1: the header, with RETURNING.
INSERT INTO orders (customer_id, employee_id, order_date, status, payment_method, shipping_cost)
VALUES (6, 4, DATE '2026-03-02', 'pending', 'card', 4.95)
RETURNING id, customer_id, employee_id, order_date, status, shipping_cost;| id | customer_id | employee_id | order_date | status | shipping_cost |
|---|---|---|---|---|---|
| 21 | 6 | 4 | 2026-03-02 | pending | 4.95 |
The order is number 21. We didn't choose that number: the engine told us.
Step 2: the three lines, in a single statement.
The prices are copied from the catalogue at the moment of the sale —it's the deliberate denormalisation of unit_price (01-05)— and here that means: €12.50, €9.75 and €3.25.
INSERT INTO order_lines (order_id, product_id, quantity, unit_price, discount) VALUES
(21, 1, 2, 12.50, 0.00),
(21, 3, 1, 9.75, 0.00),
(21, 14, 3, 3.25, 0.00)
RETURNING id, order_id, product_id, quantity, unit_price,
quantity * unit_price * (1 - discount) AS amount;| id | order_id | product_id | quantity | unit_price | amount |
|---|---|---|---|---|---|
| 48 | 21 | 1 | 2 | 12.50 | 25.0000 |
| 49 | 21 | 3 | 1 | 9.75 | 9.7500 |
| 50 | 21 | 14 | 3 | 3.25 | 9.7500 |
Lines 48, 49 and 50, following on from the 47 that were already there.
Step 3: checking the complete order.
SELECT o.id AS order_id,
c.name || ' ' || c.last_name AS customer,
e.name || ' ' || e.last_name AS sales_rep,
COUNT(*) AS lines_,
SUM(ol.quantity) AS units,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS goods,
o.shipping_cost AS shipping,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)) + o.shipping_cost, 2) AS total
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
JOIN customers AS c ON o.customer_id = c.id
JOIN employees AS e ON o.employee_id = e.id
WHERE o.id = 21
GROUP BY o.id, c.name, c.last_name, e.name, e.last_name, o.shipping_cost;| order_id | customer | sales_rep | lines_ | units | goods | shipping | total |
|---|---|---|---|---|---|---|---|
| 21 | Pau Llorens Vidal | Óscar Peris Blasco | 3 | 6 | 44.50 | 4.95 | 49.45 |
€44.50 of goods plus €4.95 of shipping: €49.45. The whole of module 4 applied to a row you created yourself.
What's missing and why. A real system would do those two insertions inside a single transaction, so that it's impossible to be left with a header and no lines if something fails halfway. And it would deduct the stock of the three products. You'll start using the first of those in the next lesson and you'll study it in depth in module 9; the second is an
UPDATE, and it's in the next lesson too.
Remember to reload. If you've run these examples, your database no longer matches the course's: there are 21 or more products, 21 orders and 50 lines. Run
greenstore.sqlagain before continuing.
Common Mistakes and Tips
- Omitting the column list. It works today and breaks the day somebody touches the table. Always list the columns.
- Inserting the
idby hand into aBY DEFAULTidentity. The sequence falls behind and the next automaticINSERTfails withduplicate key ... _pkey. Fix it withsetvalor, better, don't do it. - Using
SELECT MAX(id)to find out what you've just inserted. It's a race condition. UseRETURNING. - Pairing the columns badly in an
INSERT ... SELECT. They're paired by position. Read them twice. - Inserting children before parents.
is not present in table. Parents first, always. - Believing a multi-row
INSERTinserts what it can. It's atomic: if one tuple fails, none goes in. - Confusing
NULLwithDEFAULT.VALUES (NULL)storesNULL(or fails on aNOT NULL);VALUES (DEFAULT)applies the default value. Omitting the column is equivalent to the latter. - Writing decimals with a comma.
12,50isn't a number in SQL: it'sinvalid input syntax for type numeric(01-03). - Loading a million rows with
INSERT. That's whatCOPYis for. And if you need to transform,COPYinto staging and thenINSERT ... SELECT. - Copying
products.priceintounit_pricefrom the table instead of fixing it. A line's price is the one at the moment of the sale; if you link it to the catalogue, tomorrow your old invoices will change. - Tip: always look at the second number in
INSERT 0 N. It's the only one telling you whether it did what you expected. - Tip: use
RETURNINGeven when you don't need it. Seeing the resulting row with itsDEFAULTs applied is the quickest way to check the DDL does what you think. - Tip: for rerunnable master data,
ON CONFLICT DO NOTHING. It turns a fragile script into an idempotent one.
Exercises
Exercise 1
GreenStore is bringing in a supplier and two of its products. Write the necessary statements, in the correct order, meeting these requirements:
- Add the supplier Cooperativa La Safor, from Spain, with email
ventas@lasafor.es, active. Retrieve itsidwithRETURNING. - Add two of its products in a single statement, both in the Food category (
id1):- Organic marcona almonds 250 g, price €7.90, cost €4.20, stock 60.
- Organic Valencia oranges 5 kg, price €11.50, cost €6.00, stock 35.
- In both,
activeandadded_datemust be left at their default values, without writing them.
- A query showing the new supplier with its two products and the margin on each.
Exercise 2
Predict what happens with each of these statements on the freshly reloaded database, and if it fails, say which constraint is broken and what the message would be. Then check it.
-- a)
INSERT INTO order_lines (order_id, product_id, quantity, unit_price)
VALUES (1, 5, 0, 1.95);
-- b)
INSERT INTO customers (name, last_name, email, country)
VALUES ('Lucía', 'Martínez Soler', 'lucia.martinez@example.com', 'Spain');
-- c)
INSERT INTO employees (name, last_name, job_title, manager_id, hire_date)
VALUES ('Nerea', 'Blasco Tur', 'Sales rep', 2, '2026-03-01');
-- d)
INSERT INTO orders (customer_id, order_date, status, payment_method)
VALUES (14, '2026-03-01', 'preparing', 'card');
-- e)
INSERT INTO returns (order_id, reason, date, amount)
VALUES (21, 'Faulty product', '2026-03-05', 12.00);Exercise 3
The analysis team wants a monthly sales summary table for 2025 so they don't have to recompute it for every report.
- Create the table
monthly_saleswith:month(textYYYY-MM, primary key),orders(integer, mandatory),lines(integer, mandatory),units(integer, mandatory),revenue(NUMERIC(10,2), mandatory, non-negative) andcomputed_date(DATE, mandatory, defaulting to today). - Populate it with an
INSERT ... SELECTfrom the 2025 orders. - Check the result and verify that the sum of the
revenuecolumn matches 2025's product revenue.
Solutions
Solution 1
-- 1) The supplier first: it's the parent
INSERT INTO suppliers (name, country, email, active)
VALUES ('Cooperativa La Safor', 'Spain', 'ventas@lasafor.es', TRUE)
RETURNING id, name, country, email, active;| id | name | country | active | |
|---|---|---|---|---|
| 6 | Cooperativa La Safor | Spain | ventas@lasafor.es | true |
-- 2) The two products, in a single statement
INSERT INTO products (name, category_id, supplier_id, price, cost, stock) VALUES
('Organic marcona almonds 250 g', 1, 6, 7.90, 4.20, 60),
('Organic Valencia oranges 5 kg', 1, 6, 11.50, 6.00, 35)
RETURNING id, name, price, cost, stock, active, added_date;| id | name | price | cost | stock | active | added_date |
|---|---|---|---|---|---|---|
| 21 | Organic marcona almonds 250 g | 7.90 | 4.20 | 60 | true | 2026-03-01 |
| 22 | Organic Valencia oranges 5 kg | 11.50 | 6.00 | 35 | true | 2026-03-01 |
-- 3) The check
SELECT s.id AS supplier_id,
s.name AS supplier,
p.id AS product_id,
p.name AS product,
p.price,
p.cost,
p.price - p.cost AS margin
FROM products AS p
JOIN suppliers AS s ON p.supplier_id = s.id
WHERE s.name = 'Cooperativa La Safor'
ORDER BY p.id;| supplier_id | supplier | product_id | product | price | cost | margin |
|---|---|---|---|---|---|---|
| 6 | Cooperativa La Safor | 21 | Organic marcona almonds 250 g | 7.90 | 4.20 | 3.70 |
| 6 | Cooperativa La Safor | 22 | Organic Valencia oranges 5 kg | 11.50 | 6.00 | 5.50 |
Three decisions you had to get right: the order (the supplier before the products, because it's the parent of the FK), a single statement for the two products, and omitting active and added_date instead of writing them. And one detail: the supplier's id of 6 was never written anywhere; it was retrieved with RETURNING and used in the following INSERT. In a real application, that value would travel in a variable.
Solution 2
a) It fails. The quantity CHECK:
ERROR: new row for relation "order_lines" violates check constraint "order_lines_quantity_check" DETAIL: Failing row contains (48, 1, 5, 0, 1.95, 0.00).
CHECK (quantity > 0): a line of zero units isn't a sale. Notice that discount did get filled with its DEFAULT 0.00 before the constraint was checked.
b) It fails. The email UNIQUE:
ERROR: duplicate key value violates unique constraint "customers_email_key" DETAIL: Key (email)=(lucia.martinez@example.com) already exists.
It's 01-05's natural-key protection. The id would be different, but the email is unique: the database prevents registering the same person twice. This is exactly the scenario 05-05's UPSERT will solve.
c) It works.
Employee 9 is created, with manager_id 2 (Andrés Company Talens, who exists), salary at NULL (the column is nullable) and city at NULL. No constraint is violated: salary isn't mandatory and its CHECK (salary >= 0) gives UNKNOWN with a null, which counts as satisfied (05-01).
d) It fails. The status CHECK:
ERROR: new row for relation "orders" violates check constraint "orders_status_check" DETAIL: Failing row contains (21, 14, null, 2026-03-01, preparing, card, 0.00).
'preparing' isn't in the domain ('pending','paid','shipped','delivered','cancelled'). Look at the DETAIL: employee_id has been left NULL (a web order) and shipping_cost has taken its DEFAULT 0.00. The row was almost right.
e) It fails. FOREIGN KEY:
ERROR: insert or update on table "returns" violates foreign key constraint "returns_order_id_fkey" DETAIL: Key (order_id)=(21) is not present in table "orders".
On the freshly reloaded database there are only 20 orders. Order 21 is the one we created in section 11, not the one that's there now. It's a reminder of why it's worth reloading the script between blocks of exercises.
Solution 3
-- 1) The table
CREATE TABLE monthly_sales (
month CHAR(7) NOT NULL,
orders INTEGER NOT NULL,
lines INTEGER NOT NULL,
units INTEGER NOT NULL,
revenue NUMERIC(10,2) NOT NULL,
computed_date DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_monthly_sales PRIMARY KEY (month),
CONSTRAINT chk_monthly_revenue CHECK (revenue >= 0)
);-- 2) The load
INSERT INTO monthly_sales (month, orders, lines, units, revenue)
SELECT TO_CHAR(o.order_date, 'YYYY-MM'),
COUNT(DISTINCT o.id),
COUNT(*),
SUM(ol.quantity),
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
WHERE o.order_date >= DATE '2025-01-01'
AND o.order_date < DATE '2026-01-01'
GROUP BY TO_CHAR(o.order_date, 'YYYY-MM');| month | orders | lines | units | revenue |
|---|---|---|---|---|
| 2025-03 | 2 | 5 | 10 | 68.80 |
| 2025-04 | 2 | 5 | 14 | 61.28 |
| 2025-05 | 2 | 5 | 6 | 58.85 |
| 2025-06 | 2 | 5 | 14 | 95.48 |
| 2025-07 | 1 | 3 | 9 | 44.60 |
| 2025-08 | 1 | 2 | 3 | 48.27 |
| 2025-09 | 1 | 2 | 13 | 32.76 |
| 2025-10 | 2 | 5 | 13 | 97.20 |
| 2025-11 | 1 | 3 | 6 | 31.70 |
| 2025-12 | 2 | 5 | 7 | 64.58 |
SELECT SUM(revenue) AS total_2025,
SUM(orders) AS orders_2025,
SUM(lines) AS lines_2025,
SUM(units) AS units_2025
FROM monthly_sales;| total_2025 | orders_2025 | lines_2025 | units_2025 |
|---|---|---|---|
| 603.52 | 16 | 40 | 95 |
Ten months, 16 orders and €603.52 of product revenue in 2025. The other 4 orders and €124.43 belong to 2026: 603.52 + 124.43 = €727.95, module 4's canonical figure. It adds up.
Two observations about this table's design. The first: month as a CHAR(7) in YYYY-MM format is a natural primary key and it sorts alphabetically the same way it sorts chronologically, which is exactly why that format is used so much. The second, and more important: this table is a precomputed aggregate, that is, deliberate denormalisation (01-05, section 8). Its big problem is synchronisation: if an order dated 2025 comes in tomorrow, the table becomes stale and nobody notices. The solutions —materialized views and triggers— belong to module 10.
Conclusion
INSERT is the data's front door, and you've got it under control:
- The syntax
INSERT INTO table (columns) VALUES (...), and the rule that admits no exceptions: always list the columns. The listless form depends on the table's physical order and breaks as soon as anybody touches it. - The output
INSERT 0 N: the0is a relic, theNis what matters. - Several rows in one statement, which is what the course script does and what you should do: between 10 and 50 times faster, because the dominant cost is fixed per statement.
DEFAULTas a value andDEFAULT VALUESfor an entire default row; and the equivalence between writingDEFAULTand simply omitting the column.- The identity column: omitting it is the norm, forcing it is legal with
BY DEFAULTand makes the sequence drift, and the fix issetval(pg_get_serial_sequence(...), MAX(id))— which is exactly what the nine closing lines ofgreenstore.sqldo. Sequences aren't undone by aROLLBACK:ids have gaps and they don't count rows. RETURNING, the extension that gives back the inserted row with its generated values, and that removes the race condition ofSELECT MAX(id). Along with its equivalents by engine:LAST_INSERT_ID(),SCOPE_IDENTITY(),OUTPUT.INSERT ... SELECTfor populating history tables and duplicating catalogues transforming while copying; the columns are paired by position.- The four errors of an
INSERT, with their literal message and their diagnosis table; and the fact that a multi-rowINSERTis atomic. - The insertion order: parents before children, always.
COPYand\copyfor bulk loading, 5 to 20 times faster, and the professional staging +INSERT ... SELECTpattern.- And the complete case: adding a product and recording order 21 with its three lines and its €49.45 total, chaining two
INSERTs withRETURNING.
You know how to create tables and fill them. What you don't know yet is how to correct. In the next lesson, The UPDATE Statement, the tone changes: up to now, a mistake of yours created one extra row you could delete; from now on, a mistake of yours can overwrite twenty correct rows and leave you with no way of knowing what was there before. You'll see the professional protocol that stops that happening —write the SELECT first, count the rows, and only then turn it into an UPDATE—, the habit of working with BEGIN and ROLLBACK as a safety net, and everything UPDATE can do: several columns at once, calculations over the previous value, updates driven by another table with FROM, and RETURNING to see exactly what you've changed.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
