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

  1. The syntax, and why you should always list the columns
  2. Inserting several rows in a single statement
  3. DEFAULT and DEFAULT VALUES
  4. The identity column: omitting it, forcing it and setval
  5. RETURNING: retrieving what you've just inserted
  6. INSERT ... SELECT: inserting the result of a query
  7. The four errors of an INSERT and how to diagnose them
  8. The order of insertion when there are foreign keys
  9. ON CONFLICT DO NOTHING, in passing
  10. Bulk loading: COPY and \copy against INSERT
  11. A complete example: adding a product and recording an order
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. The syntax, and why you should always list the columns

INSERT INTO table (column1, column2, ...)
VALUES (value1, value2, ...);

Adding a supplier:

INSERT INTO suppliers (name, country, email, active)
VALUES ('Cooperativa La Safor', 'Spain', 'ventas@lasafor.es', TRUE);
INSERT 0 1

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);
INSERT 0 1

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:

INSERT INTO suppliers (country, name)
VALUES ('Portugal', 'Quinta Bio Douro');
INSERT 0 1

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.

  1. 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');
INSERT 0 3

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).

  1. DEFAULT and DEFAULT VALUES

The 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);
INSERT 0 1

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;
INSERT 0 1
SELECT * FROM demo_defaults;
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.

  1. The identity column: omitting it, forcing it and setval

This 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:

INSERT INTO categories (id, name, description)
VALUES (7, 'Babies', 'Organic-certified baby care');
INSERT 0 1

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:

INSERT INTO categories (name, description)
VALUES ('Pets', 'Food and accessories for pets');
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:

SELECT setval(pg_get_serial_sequence('categories', 'id'),
              (SELECT MAX(id) FROM categories));
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 uses BY DEFAULT for 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.

  1. RETURNING: retrieving what you've just inserted

A 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
INSERT 0 1

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:

  1. INSERT INTO orders ...
  2. SELECT MAX(id) FROM ordershere's the flaw
  3. INSERT 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.

RETURNING works the same way in UPDATE (05-03) and in DELETE (05-04), and there it's even more useful: it lets you see exactly what you've changed or what you've deleted.

  1. INSERT ... SELECT: inserting the result of a query

Instead 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.

INSERT INTO target_table (col1, col2, ...)
SELECT expr1, expr2, ...
FROM ...
WHERE ...;

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
);
CREATE TABLE
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';
INSERT 0 14

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;
INSERT 0 4
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;
INSERT 0 5
SELECT * FROM top_products ORDER BY position;
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.

  1. The four errors of an INSERT and how to diagnose them

An INSERT can fail for four reasons, one per kind of constraint. Knowing the literal messages saves you hours.

7.1. NOT NULL violation

INSERT INTO products (name, category_id, price)
VALUES (NULL, 1, 4.50);
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

INSERT INTO categories (name, description)
VALUES ('Drinks', 'Duplicate');
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.

  1. 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);
INSERT 0 1
INSERT 0 1

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.

  1. ON CONFLICT DO NOTHING, in passing

If 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;
INSERT 0 0

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.

  1. Bulk loading: COPY and \copy against INSERT

For 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 4500

\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)
COPY 4500

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)
COPY 20

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: COPY into a staging table with no constraints, and from there INSERT ... SELECT transforming 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.

  1. 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
INSERT 0 1

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
INSERT 0 1

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
INSERT 0 3

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.sql again 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 id by hand into a BY DEFAULT identity. The sequence falls behind and the next automatic INSERT fails with duplicate key ... _pkey. Fix it with setval or, better, don't do it.
  • Using SELECT MAX(id) to find out what you've just inserted. It's a race condition. Use RETURNING.
  • 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 INSERT inserts what it can. It's atomic: if one tuple fails, none goes in.
  • Confusing NULL with DEFAULT. VALUES (NULL) stores NULL (or fails on a NOT NULL); VALUES (DEFAULT) applies the default value. Omitting the column is equivalent to the latter.
  • Writing decimals with a comma. 12,50 isn't a number in SQL: it's invalid input syntax for type numeric (01-03).
  • Loading a million rows with INSERT. That's what COPY is for. And if you need to transform, COPY into staging and then INSERT ... SELECT.
  • Copying products.price into unit_price from 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 RETURNING even when you don't need it. Seeing the resulting row with its DEFAULTs 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:

  1. Add the supplier Cooperativa La Safor, from Spain, with email ventas@lasafor.es, active. Retrieve its id with RETURNING.
  2. Add two of its products in a single statement, both in the Food category (id 1):
    • 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, active and added_date must be left at their default values, without writing them.
  3. 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.

  1. Create the table monthly_sales with: month (text YYYY-MM, primary key), orders (integer, mandatory), lines (integer, mandatory), units (integer, mandatory), revenue (NUMERIC(10,2), mandatory, non-negative) and computed_date (DATE, mandatory, defaulting to today).
  2. Populate it with an INSERT ... SELECT from the 2025 orders.
  3. Check the result and verify that the sum of the revenue column 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 email active
6 Cooperativa La Safor Spain ventas@lasafor.es true
INSERT 0 1
-- 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
INSERT 0 2
-- 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.

INSERT 0 1

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)
);
CREATE TABLE
-- 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');
INSERT 0 10
-- 3) The check
SELECT month, orders, lines, units, revenue
FROM monthly_sales
ORDER BY month;
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: the 0 is a relic, the N is 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.
  • DEFAULT as a value and DEFAULT VALUES for an entire default row; and the equivalence between writing DEFAULT and simply omitting the column.
  • The identity column: omitting it is the norm, forcing it is legal with BY DEFAULT and makes the sequence drift, and the fix is setval(pg_get_serial_sequence(...), MAX(id)) — which is exactly what the nine closing lines of greenstore.sql do. Sequences aren't undone by a ROLLBACK: 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 of SELECT MAX(id). Along with its equivalents by engine: LAST_INSERT_ID(), SCOPE_IDENTITY(), OUTPUT.
  • INSERT ... SELECT for 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-row INSERT is atomic.
  • The insertion order: parents before children, always.
  • COPY and \copy for bulk loading, 5 to 20 times faster, and the professional staging + INSERT ... SELECT pattern.
  • And the complete case: adding a product and recording order 21 with its three lines and its €49.45 total, chaining two INSERTs with RETURNING.

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

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved