BiblioRed's seven tables exist and they are empty. This lesson fills them and teaches you how to interrogate them. It is the most hands-on lesson of the module: here you learn the four operations that cover 90% of daily work with a database —insert, query, modify and delete—, known collectively as CRUD (Create, Read, Update, Delete).

We deliberately restrict ourselves to a single table per query. Reassembling information spread across several tables is a topic with enough substance for its own lesson (02-04), and grouping and summarizing, for the one after that (02-05). Here we lay the foundations: without mastering WHERE and ORDER BY on one table, no JOIN will ever come out right.

The lesson's deliverable is BiblioRed's test data set: four branches, eight authors, ten members, nine books, fifteen copies, twelve loans and five reservations. All fictional and all consistent with one another. We will use them until the end of the course, so run them carefully and save them in a file.

Contents

  1. INSERT: adding rows
  2. Deliverable: the BiblioRed data set
  3. Resetting the identifier generators
  4. SELECT: projection, aliases and DISTINCT
  5. WHERE: filtering rows
  6. ORDER BY: sorting the result
  7. LIMIT and OFFSET: pagination
  8. UPDATE: modifying existing rows
  9. DELETE: removing rows
  10. TRUNCATE: emptying a whole table
  11. Common mistakes and tips
  12. Exercises
  13. Conclusion

  1. INSERT: adding rows

Basic form

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

Let's start with BiblioRed's branches. The network operates in the fictional city of Vallmar and has four libraries.

INSERT INTO branches (name, address, phone, opening_date)
VALUES ('Central', '1 Main Square', '900 100 001', '1998-04-12');
INSERT 0 1

PostgreSQL's output reads like this: INSERT <oid> <number of rows inserted>. The first number is a historical relic and is always 0; the second is the one that matters.

Notice three things:

  • We did not supply branch_id. The column is GENERATED BY DEFAULT AS IDENTITY, so PostgreSQL assigns it 1.
  • The date goes between single quotes, in ISO format YYYY-MM-DD. The manager converts it to DATE because the column is of that type.
  • The phone number goes between quotes, even though it looks like a number: it is VARCHAR, as we decided in the previous lesson.

Without a column list: the fragile form

SQL lets you omit the column list if you supply a value for every column, in the exact order of the table definition:

-- Legal, but do not do it
INSERT INTO branches VALUES (DEFAULT, 'North', '45 Park Avenue', '900 100 002', '2005-09-30');

Why avoid it? Because the day somebody adds a column with ALTER TABLE, or reorders the definition, every INSERT without a column list will break or, worse, insert values into the wrong column without raising an error. Always write the column list. It is the first rule of hygiene in professional SQL.

Let's do it properly:

INSERT INTO branches (name, address, phone, opening_date)
VALUES ('North', '45 Park Avenue', '900 100 002', '2005-09-30');

This is the North branch, which from now on will have branch_id = 2.

Inserting several rows at once

You can chain several value tuples separated by commas. It is faster (a single operation instead of N) and more readable:

INSERT INTO branches (name, address, phone, opening_date) VALUES
    ('South', '12 Olive Street',  '900 100 003', '2011-02-18'),
    ('East',  '8 Harbor Road',    NULL,          '2019-06-25');
INSERT 0 2

The East branch does not have its own phone number yet: we write NULL without quotes. If we wrote 'NULL' we would be storing the four-letter string N-U-L-L, which is not the same thing at all.

Another option is to omit the column from the list: whatever is not mentioned is left at NULL (or at its default value, if it had one).

-- (Illustrative only, do NOT run it: it would create a second East branch.)
-- Equivalent for the phone, but explicit beats implicit.
INSERT INTO branches (name, address, opening_date)
VALUES ('East', '8 Harbor Road', '2019-06-25');

RETURNING: finding out which identifier was generated

When you let the manager generate the key, an immediate practical problem arises: which number did it get? You will need it to insert the child rows. PostgreSQL solves it with RETURNING:

INSERT INTO authors (first_name, last_name, nationality, birth_year)
VALUES ('Félix J.', 'Palma', 'Spanish', 1968)
RETURNING author_id, last_name;
 author_id | last_name
-----------+-----------
         1 | Palma
(1 row)

RETURNING works the same way with UPDATE and DELETE, and can return any expression, including *. It is a very convenient PostgreSQL extension that avoids the classic "insert and then query".

SQLite has no RETURNING until version 3.35 (2021); in earlier versions you use the last_insert_rowid() function:

INSERT INTO authors (first_name, last_name) VALUES ('Félix J.', 'Palma');
SELECT last_insert_rowid();

What the manager checks on every INSERT

Before accepting the row, the manager verifies the three integrity rules from lesson 02-01:

INSERT INTO branches (name, address) VALUES ('North', 'Another address');
ERROR:  duplicate key value violates unique constraint "uq_branches_name"
DETAIL:  Key (name)=(North) already exists.
INSERT INTO members (first_name, last_name, join_date, branch_id, active)
VALUES ('Test', 'Test', '2026-01-01', 99, TRUE);
ERROR:  insert or update on table "members" violates foreign key constraint "fk_members_branch"
DETAIL:  Key (branch_id)=(99) is not present in table "branches".

This is exactly what BiblioRed's spreadsheet did not do. Here the error is not a nuisance: it is the system doing its job.

  1. Deliverable: the BiblioRed data set

This is the load script. Run it whole and in this order (children need their parents) and save it as biblioredb_data.sql.

One nuance about the identifiers you will see:

  • In branches, authors, copies, loans and reservations we let the manager generate them.
  • In members and books we impose them: BiblioRed is migrating from the spreadsheet and wants to keep the card numbers (Marta Alsina has been member 14 since 2021) and the shelfmarks from the old catalog (331 is "The Map of Time"). It is a thoroughly realistic case, and in section 3 we will see the side effect it causes.
-- ============================================================
--  BiblioRed - Test data set (all fictional)
--  Module 2, lesson 02-03. Dialect: PostgreSQL
-- ============================================================

-- STEP 0: start from scratch. The examples in the previous section
-- already inserted two branches and one author; we delete them so that the
-- script is the only source of data. The order is the reverse of the
-- dependency order: children first, parents afterwards.
DELETE FROM reservations;
DELETE FROM loans;
DELETE FROM copies;
DELETE FROM books;
DELETE FROM members;
DELETE FROM authors;
DELETE FROM branches;
ALTER TABLE branches     ALTER COLUMN branch_id      RESTART WITH 1;
ALTER TABLE authors      ALTER COLUMN author_id      RESTART WITH 1;
ALTER TABLE copies       ALTER COLUMN copy_id        RESTART WITH 1;
ALTER TABLE loans        ALTER COLUMN loan_id        RESTART WITH 1;
ALTER TABLE reservations ALTER COLUMN reservation_id RESTART WITH 1;

-- BRANCHES: the four libraries of the Vallmar network.
-- Identifiers 1..4 generated by the manager: Central=1, North=2, South=3, East=4.
INSERT INTO branches (name, address, phone, opening_date) VALUES
    ('Central', '1 Main Square',   '900 100 001', '1998-04-12'),
    ('North',   '45 Park Avenue',  '900 100 002', '2005-09-30'),
    ('South',   '12 Olive Street', '900 100 003', '2011-02-18'),
    ('East',    '8 Harbor Road',   NULL,          '2019-06-25');

-- AUTHORS: identifiers 1..8 generated by the manager.
-- Marina Escolá (8) has no work in the collection yet:
-- she will be useful for practicing next lesson's LEFT JOINs.
INSERT INTO authors (first_name, last_name, nationality, birth_year) VALUES
    ('Félix J.', 'Palma',      'Spanish',    1968),
    ('Ken',      'Follett',    'British',    1949),
    ('Irene',    'Valcárcel',  'Spanish',    1975),
    ('Óscar',    'Barreda',    'Spanish',    1981),
    ('Nadia',    'Sorrentino', 'Italian',    1970),
    ('Hugo',     'Lemos',      'Portuguese', 1958),
    ('Clara',    'Ordóñez',    'Spanish',    1988),
    ('Marina',   'Escolá',     'Spanish',    1992);

-- MEMBERS: card numbers inherited from the spreadsheet (11..20).
-- Pau Miralles (19) did not give an email address: his email stays NULL.
-- Ramón Etxebarri (13) has lapsed: active = FALSE.
INSERT INTO members (member_id, first_name, last_name, email, join_date, branch_id, active) VALUES
    (11, 'Álvaro', 'Ferrán',    'alvaro.ferran@example.org',   '2018-01-22', 1, TRUE),
    (12, 'Sonia',  'Quiroga',   'sonia.quiroga@example.org',   '2019-05-03', 3, TRUE),
    (13, 'Ramón',  'Etxebarri', 'ramon.etxebarri@example.org', '2020-11-14', 1, FALSE),
    (14, 'Marta',  'Alsina',    'marta.alsina@example.org',    '2021-03-08', 2, TRUE),
    (15, 'Iván',   'Pereda',    'ivan.pereda@example.org',     '2021-09-19', 2, TRUE),
    (16, 'Nuria',  'Bastos',    'nuria.bastos@example.org',    '2022-01-30', 1, TRUE),
    (17, 'Diego',  'Salom',     'diego.salom@example.org',     '2023-02-11', 3, TRUE),
    (18, 'Lucía',  'Vendrell',  'lucia.vendrell@example.org',  '2023-07-05', 4, TRUE),
    (19, 'Pau',    'Miralles',  NULL,                          '2024-04-16', 2, TRUE),
    (20, 'Elena',  'Roig',      'elena.roig@example.org',      '2024-10-01', 1, TRUE);

-- BOOKS: inherited shelfmarks (331..339).
-- Number 339 is an old municipal publication: NO ISBN and NO cataloged
-- author. It is living proof of why the ISBN could not be the
-- primary key (lesson 02-01).
INSERT INTO books (book_id, isbn, title, author_id, publisher, publication_year, language) VALUES
    (331, '9788401339097', 'The Map of Time',          1,    'Andana Press',        2008, 'es'),
    (332, '9788401337208', 'The Pillars of the Earth', 2,    'Andana Press',        1989, 'es'),
    (333, '9788412007701', 'House of Tides',           3,    'Marlia Editions',     2015, 'es'),
    (334, '9788412007702', 'Algebra for the Impatient', 4,   'North Technical Press', 2019, 'es'),
    (335, '9788412007703', 'Ravenna Notebooks',        5,    'Marlia Editions',     2012, 'es'),
    (336, '9788412007704', 'Winter of the Birds',      6,    'Andana Press',        2021, 'es'),
    (337, '9788412007705', 'Delta Trails',             7,    'Marlia Editions',     2017, 'ca'),
    (338, '9788412007706', 'Urban Gardening Handbook', 4,    'North Technical Press', 2023, 'es'),
    (339, NULL,            'Ensanche Records (1904)',  NULL, 'Vallmar City Council', 1904, 'es');

-- COPIES: the physical objects. Identifiers 1..15 generated
-- in the order of this list; EJ-3081 will be copy_id 1.
INSERT INTO copies (code, book_id, branch_id, status, acquisition_date) VALUES
    ('EJ-3081', 331, 2, 'on_loan',   '2019-03-14'),
    ('EJ-3082', 331, 1, 'available', '2019-03-14'),
    ('EJ-3083', 331, 3, 'available', '2021-06-01'),
    ('EJ-3084', 332, 1, 'on_loan',   '2015-11-20'),
    ('EJ-3085', 332, 2, 'available', '2015-11-20'),
    ('EJ-3086', 333, 1, 'on_loan',   '2016-02-09'),
    ('EJ-3087', 334, 2, 'available', '2020-01-15'),
    ('EJ-3088', 334, 4, 'in_repair', '2020-01-15'),
    ('EJ-3089', 335, 3, 'available', '2013-05-04'),
    ('EJ-3090', 336, 2, 'on_loan',   '2022-04-27'),
    ('EJ-3091', 336, 1, 'available', '2022-04-27'),
    ('EJ-3092', 337, 4, 'available', '2018-10-02'),
    ('EJ-3093', 338, 1, 'available', '2023-09-11'),
    ('EJ-3094', 338, 3, 'withdrawn', '2023-09-11'),
    ('EJ-3095', 339, 1, 'available', '2003-01-15');

-- LOANS: eight closed and four open.
-- return_date NULL = loan still in progress.
-- The four open loans correspond to the four copies
-- whose status is 'on_loan' (1, 4, 6 and 10). Fully consistent.
INSERT INTO loans
    (member_id, copy_id, loan_date, due_date, return_date, surcharge) VALUES
    (14,  2, '2026-03-02', '2026-03-23', '2026-03-19', 0.00),
    (15,  1, '2026-03-05', '2026-03-26', '2026-04-02', 1.40),
    (16,  5, '2026-03-11', '2026-04-01', '2026-03-30', 0.00),
    (14,  7, '2026-04-06', '2026-04-27', '2026-04-25', 0.00),
    (18,  9, '2026-04-12', '2026-05-03', '2026-05-10', 1.40),
    (16,  4, '2026-05-04', '2026-05-25', '2026-05-22', 0.00),
    (11,  1, '2026-05-08', '2026-05-29', '2026-05-27', 0.00),
    (12, 12, '2026-05-19', '2026-06-09', '2026-06-30', 4.20),
    (14,  1, '2026-07-14', '2026-08-04', NULL,         NULL),
    (15,  4, '2026-07-18', '2026-08-08', NULL,         NULL),
    (17,  6, '2026-07-21', '2026-08-11', NULL,         NULL),
    (19, 10, '2026-07-25', '2026-08-15', NULL,         NULL);

-- RESERVATIONS: they point at the BOOK (the work), not at the copy.
INSERT INTO reservations (member_id, book_id, reservation_date, expiry_date, status) VALUES
    (16, 331, '2026-07-20', '2026-08-10', 'active'),
    (18, 332, '2026-07-22', '2026-08-12', 'active'),
    (14, 336, '2026-06-30', '2026-07-20', 'fulfilled'),
    (11, 333, '2026-07-05', '2026-07-25', 'cancelled'),
    (15, 331, '2026-07-28', '2026-08-18', 'active');

Checking the load

SELECT 'branches' AS table_name, COUNT(*) AS row_count FROM branches
UNION ALL SELECT 'authors',      COUNT(*) FROM authors
UNION ALL SELECT 'members',      COUNT(*) FROM members
UNION ALL SELECT 'books',        COUNT(*) FROM books
UNION ALL SELECT 'copies',       COUNT(*) FROM copies
UNION ALL SELECT 'loans',        COUNT(*) FROM loans
UNION ALL SELECT 'reservations', COUNT(*) FROM reservations;

(COUNT and UNION ALL belong to lessons 02-05 and 02-04; here we only use it as a control count.)

table_name row_count
branches 4
authors 8
members 10
books 9
copies 15
loans 12
reservations 5

And the correspondence between codes and copy identifiers, which you will need in order to understand the loans:

SELECT copy_id, code, book_id FROM copies ORDER BY copy_id LIMIT 4;
copy_id code book_id
1 EJ-3081 331
2 EJ-3082 331
3 EJ-3083 331
4 EJ-3084 332

If your counts match, you have the same data set as the rest of the course.

Notes for SQLite

Three changes to the script:

PRAGMA foreign_keys = ON;   -- in every session!
  • TRUE/FALSE in members.active1/0.
  • The ALTER TABLE ... RESTART WITH lines from step 0 do not exist in SQLite: remove them. With INTEGER PRIMARY KEY and no AUTOINCREMENT, the next identifier is worked out on its own from the existing maximum.
  • Dates are written the same way ('2026-03-02'), but they are stored as text. As long as you use the ISO format, comparisons and sorting will keep working, because the alphabetical order of YYYY-MM-DD matches the chronological one. That is exactly why that format is the right one.

  1. Resetting the identifier generators

Here comes the promised side effect. In members and books we inserted explicit identifiers, but the column's generator never found out: it still points at 1. Let's check it by signing up a member without stating an identifier:

INSERT INTO members (first_name, last_name, join_date, branch_id, active)
VALUES ('Test', 'Dummy', '2026-08-01', 1, TRUE)
RETURNING member_id;
 member_id
-----------
         1
(1 row)

The new member has been given 1, when BiblioRed's card numbers start at 11. Nothing has failed, but the mismatch is already sown: the next ten sign-ups would take 2, 3… and the eleventh would collide:

ERROR:  duplicate key value violates unique constraint "pk_members"
DETAIL:  Key (member_id)=(11) already exists.

It is one of the most baffling errors for a beginner, because it shows up weeks after the load and with no apparent connection to it. The cause is always the same: keys were inserted by hand without resynchronizing the generator. Let's delete the test member and fix it:

DELETE FROM members WHERE last_name = 'Dummy';

The solution in PostgreSQL:

ALTER TABLE members ALTER COLUMN member_id RESTART WITH 21;
ALTER TABLE books   ALTER COLUMN book_id   RESTART WITH 340;

Or, automatically and without having to eyeball the maximum:

SELECT setval(pg_get_serial_sequence('members', 'member_id'),
              (SELECT MAX(member_id) FROM members));

In SQLite the problem does not exist if the key was declared as INTEGER PRIMARY KEY without AUTOINCREMENT: the next value is computed as the current maximum plus one, so it adjusts itself.

  1. SELECT: projection, aliases and DISTINCT

SELECT is SQL's most used statement and the one that takes up the most lessons in this course. Its minimal form:

SELECT column1, column2 FROM table;

Projection: choosing columns

SELECT first_name, last_name, join_date FROM members;
first_name last_name join_date
Álvaro Ferrán 2018-01-22
Sonia Quiroga 2019-05-03
Ramón Etxebarri 2020-11-14
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Nuria Bastos 2022-01-30
Diego Salom 2023-02-11
Lucía Vendrell 2023-07-05
Pau Miralles 2024-04-16
Elena Roig 2024-10-01

This is relational algebra's projection π. Remember that the order in which the rows appear is not guaranteed without ORDER BY: here they come out in insertion order because the table is small and freshly loaded, but do not count on it.

SELECT *: convenient and dangerous

SELECT * FROM branches;
branch_id name address phone opening_date
1 Central 1 Main Square 900 100 001 1998-04-12
2 North 45 Park Avenue 900 100 002 2005-09-30
3 South 12 Olive Street 900 100 003 2011-02-18
4 East 8 Harbor Road (NULL) 2019-06-25

* means "all the columns". It is perfect for exploring in the console, but do not use it in application code: you pull data you do not need over the network, and if tomorrow somebody adds a column, your program receives something it was not expecting. In a saved script, explicit columns.

Notice that the NULL in East's phone appears as an empty cell in psql. You can make it visible:

biblioredb=> \pset null '(null)'

Aliases with AS

An alias renames a column in the result, without touching the table. It is relational algebra's rename operator ρ.

SELECT code      AS label,
       status    AS situation,
       acquisition_date AS "purchase date"
FROM copies
WHERE branch_id = 4;
label situation purchase date
EJ-3088 in_repair 2020-01-15
EJ-3092 available 2018-10-02

Points to remember:

  • AS is optional (code label works just as well), but writing it makes the SQL far more readable.
  • For an alias to carry spaces, capitals or accents it has to go between double quotes: it is an identifier. Here it is acceptable, because the alias only lives in the output.

You can also compute new columns:

SELECT first_name || ' ' || last_name AS full_name,
       join_date
FROM members
WHERE branch_id = 2;
full_name join_date
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Pau Miralles 2024-04-16

|| is the standard concatenation operator (it works in PostgreSQL and SQLite; MySQL uses CONCAT()).

Beware of NULL in concatenations: 'Pau' || NULL gives NULL, not 'Pau'. If last_name could be null, the full name would disappear entirely. In 02-05 we will see COALESCE, which solves exactly this.

DISTINCT: removing duplicates

SELECT status FROM copies;

It returns 15 rows with a lot of repetition. With DISTINCT:

SELECT DISTINCT status FROM copies ORDER BY status;
status
available
in_repair
on_loan
withdrawn

DISTINCT applies to the complete combination of the selected columns, not to the first one:

SELECT DISTINCT branch_id, status
FROM copies
ORDER BY branch_id, status;
branch_id status
1 available
1 on_loan
2 available
2 on_loan
3 available
3 withdrawn
4 available
4 in_repair

Those are the eight distinct combinations that exist, out of the 15 original rows.

Remember from lesson 02-01: in relational algebra, projection always removes duplicates; in SQL you have to ask for it. DISTINCT forces the manager to sort or to build a hash table, so it has a cost: do not add it "just in case".

  1. WHERE: filtering rows

WHERE is the algebra's selection σ: it keeps the rows whose condition is TRUE (remember: UNKNOWN does not pass).

Comparison operators

Operator Meaning
= Equal
<> or != Not equal
<, >, <=, >= Less than, greater than, less or equal, greater or equal
SELECT title, publication_year
FROM books
WHERE publication_year > 2015
ORDER BY publication_year;
title publication_year
Delta Trails 2017
Algebra for the Impatient 2019
Winter of the Birds 2021
Urban Gardening Handbook 2023

Comparisons also work on text (alphabetical order according to the locale) and on dates:

SELECT first_name, last_name, join_date
FROM members
WHERE join_date >= '2023-01-01'
ORDER BY join_date;
first_name last_name join_date
Diego Salom 2023-02-11
Lucía Vendrell 2023-07-05
Pau Miralles 2024-04-16
Elena Roig 2024-10-01

Logical operators: AND, OR, NOT

SELECT code, status, branch_id
FROM copies
WHERE branch_id = 1 AND status = 'available';
code status branch_id
EJ-3082 available 1
EJ-3091 available 1
EJ-3093 available 1
EJ-3095 available 1

AND binds more tightly than OR, just as multiplication does over addition. This causes silent errors:

-- What was wanted: the copies at branches 1 or 2 that are on loan
-- What it does: the ones at branch 1 (in any status)
--               PLUS the ones at branch 2 that are on loan
SELECT code, branch_id, status FROM copies
WHERE branch_id = 1 OR branch_id = 2 AND status = 'on_loan';

It returns 7 rows: the six from branch 1 plus EJ-3081. With parentheses:

SELECT code, branch_id, status FROM copies
WHERE (branch_id = 1 OR branch_id = 2) AND status = 'on_loan';
code branch_id status
EJ-3081 2 on_loan
EJ-3084 1 on_loan
EJ-3086 1 on_loan

Advice: use parentheses whenever you mix AND and OR, even if you know the precedence. Whoever reads your query a year from now will be grateful.

BETWEEN: ranges

SELECT title, publication_year
FROM books
WHERE publication_year BETWEEN 2010 AND 2019
ORDER BY publication_year;
title publication_year
Ravenna Notebooks 2012
House of Tides 2015
Delta Trails 2017
Algebra for the Impatient 2019

BETWEEN a AND b is syntactic sugar for >= a AND <= b: both endpoints are included. It is the source of a classic error with dates and times: BETWEEN '2026-03-01' AND '2026-03-31' on a TIMESTAMP column leaves out everything that happened on the 31st after midnight, because 2026-03-31 09:00 is greater than 2026-03-31 00:00. With DATE columns like BiblioRed's there is no problem.

IN: membership of a list

SELECT code, status
FROM copies
WHERE status IN ('in_repair', 'withdrawn');
code status
EJ-3088 in_repair
EJ-3094 withdrawn

IN is equivalent to a chain of ORs, but it is far more readable. There is also NOT IN:

SELECT code, status FROM copies WHERE status NOT IN ('available', 'on_loan');

Same result as before.

Important warning about NOT IN and NULL: if the list contains a NULL, NOT IN returns no rows at all, because of the three-valued logic from lesson 02-01. x NOT IN (1, 2, NULL) is equivalent to x <> 1 AND x <> 2 AND x <> NULL, and that last term is always UNKNOWN. With hand-written lists it does not happen; with lists coming from a subquery (lesson 02-04) it is a common trap.

LIKE and ILIKE: pattern matching in text

Two wildcards:

Wildcard Means
% Zero or more characters of any kind
_ Exactly one character of any kind
SELECT title FROM books WHERE title LIKE '%the%';
title
The Pillars of the Earth
Algebra for the Impatient
Winter of the Birds

LIKE is case-sensitive in PostgreSQL:

SELECT title FROM books WHERE title LIKE 'the %';
(0 rows)

No title starts with a lowercase the . To ignore case, PostgreSQL offers ILIKE (the I stands for insensitive), which is not standard but is extremely handy:

SELECT title FROM books WHERE title ILIKE 'the %';
title
The Map of Time
The Pillars of the Earth

A portable alternative, valid in SQLite too:

SELECT title FROM books WHERE LOWER(title) LIKE 'the %';

In SQLite, LIKE is already case-insensitive for ASCII characters (but not for accented vowels), and ILIKE does not exist. It is one of the differences that most surprise people when porting queries.

Example with _:

SELECT code FROM copies WHERE code LIKE 'EJ-308_';
code
EJ-3080…EJ-3089 → the ten codes of the first batch

Specifically it returns EJ-3081 to EJ-3089: nine rows, because EJ-3080 does not exist.

IS NULL: the absence of a value

Here we come back to the trap we announced in 02-01.

-- WRONG: zero rows, always, with no error
SELECT member_id, first_name FROM members WHERE email = NULL;
(0 rows)
-- RIGHT
SELECT member_id, first_name, last_name FROM members WHERE email IS NULL;
member_id first_name last_name
19 Pau Miralles

And its complement, which in BiblioRed has a very specific meaning:

-- The loans still open: there is no return date
SELECT loan_id, member_id, copy_id, due_date
FROM loans
WHERE return_date IS NULL
ORDER BY due_date;
loan_id member_id copy_id due_date
9 14 1 2026-08-04
10 15 4 2026-08-08
11 17 6 2026-08-11
12 19 10 2026-08-15

Four open loans, which match exactly the four copies with status on_loan. The database is consistent.

And a check on the other NULL trap, the one with inequalities:

SELECT COUNT(*) FROM loans WHERE surcharge <> 0;   -- 3
SELECT COUNT(*) FROM loans WHERE surcharge = 0;    -- 5
-- 3 + 5 = 8, not 12: the four loans with a NULL surcharge are missing

Neither query sees the NULLs. If you want the loans "with no surcharge outstanding", you have to say so: WHERE surcharge = 0 OR surcharge IS NULL.

  1. ORDER BY: sorting the result

Because a relation is a set and has no order, ORDER BY is the only way to guarantee one.

SELECT title, publication_year FROM books ORDER BY publication_year DESC;
title publication_year
Urban Gardening Handbook 2023
Winter of the Birds 2021
Algebra for the Impatient 2019
Delta Trails 2017
House of Tides 2015
Ravenna Notebooks 2012
The Map of Time 2008
The Pillars of the Earth 1989
Ensanche Records (1904) 1904

ASC (ascending) is the default; DESC reverses it.

Several criteria

It sorts by the first one and, within ties, by the second:

SELECT branch_id, last_name, first_name
FROM members
ORDER BY branch_id ASC, last_name ASC;
branch_id last_name first_name
1 Bastos Nuria
1 Etxebarri Ramón
1 Ferrán Álvaro
1 Roig Elena
2 Alsina Marta
2 Miralles Pau
2 Pereda Iván
3 Quiroga Sonia
3 Salom Diego
4 Vendrell Lucía

Each criterion carries its own ASC/DESC: ORDER BY branch_id ASC, join_date DESC is perfectly valid.

NULLS FIRST and NULLS LAST

Where does a NULL go when sorting? The standard leaves it open, and PostgreSQL places them last in ASC and first in DESC (equivalent to treating them as the largest value). SQLite does the opposite: it puts them first in ASC.

Since we do not want to depend on the manager, you specify it:

SELECT loan_id, return_date
FROM loans
ORDER BY return_date DESC NULLS LAST, loan_id;
loan_id return_date
8 2026-06-30
7 2026-05-27
6 2026-05-22
5 2026-05-10
4 2026-04-25
2 2026-04-02
3 2026-03-30
1 2026-03-19
9 (NULL)
10 (NULL)
11 (NULL)
12 (NULL)

Notice the second criterion, loan_id: without it, the order among the four NULLs would be arbitrary. When order really matters, always finish with a criterion that breaks ties unambiguously, typically the primary key.

NULLS FIRST/NULLS LAST is standard syntax and works in PostgreSQL; SQLite supports it from version 3.30.

Sorting by alias or by position

SELECT first_name || ' ' || last_name AS full_name FROM members ORDER BY full_name;
SELECT first_name, last_name FROM members ORDER BY 2;   -- by the 2nd column: last_name

Sorting by alias is legitimate and readable. Sorting by position number works, but it is fragile: if somebody reorders the SELECT list, the query silently changes meaning. Avoid it.

Text sorting and collations

ORDER BY title puts "Algebra for the Impatient" first under an en_US.UTF-8 locale, which sorts case-insensitively and ignores punctuation; with the C collation (byte by byte) every uppercase letter comes before every lowercase one, so a title typed in lower case would end up after all the rest. It is not a bug: it is the collation. You can see yours with SHOW lc_collate; in PostgreSQL.

  1. LIMIT and OFFSET: pagination

SELECT title FROM books ORDER BY title LIMIT 3;
title
Algebra for the Impatient
Delta Trails
Ensanche Records (1904)

OFFSET skips rows before it starts counting:

SELECT title FROM books ORDER BY title LIMIT 3 OFFSET 3;
title
House of Tides
Ravenna Notebooks
The Map of Time

This is the mechanics behind the pagination of any web catalog: page N is obtained with LIMIT size OFFSET (N-1) * size.

Two warnings:

  1. LIMIT without ORDER BY makes no sense. "Give me 3 rows out of the 9" without saying which means "give me any 3", and they can be different on every run. Worse still: if you paginate without sorting, the same row can show up on page 1 and on page 3, and another one may never show up at all.
  2. A large OFFSET is slow. To reach row 100,000 the manager has to produce and throw away the previous 100,000. In large catalogs, key-based pagination techniques are used; that is a performance topic (lesson 06-03).

The standard syntax is OFFSET 3 ROWS FETCH FIRST 3 ROWS ONLY, more verbose and less used. LIMIT/OFFSET works in PostgreSQL, SQLite and MySQL.

  1. UPDATE: modifying existing rows

UPDATE table SET column1 = value1, column2 = value2 WHERE condition;

Before we start: the examples in this section and the next one modify the data set we have just loaded, and lessons 02-04 and 02-05 take it as given. At the end of each example we include the statement that undoes the change. Run it.

The habit that saves databases

Before any UPDATE or DELETE, run the same condition with a SELECT. If the SELECT returns the rows you expected, the modification will touch those same rows.

-- Step 1: check the scope
SELECT member_id, first_name, last_name, email FROM members WHERE member_id = 19;
member_id first_name last_name email
19 Pau Miralles (NULL)
-- Step 2: now yes, modify
UPDATE members SET email = 'pau.miralles@example.org' WHERE member_id = 19;
UPDATE 1

UPDATE 1 confirms that exactly one row was touched. If you see a larger number than expected, something went wrong, and in PostgreSQL, if you are inside a transaction, you are still in time (lesson 06-01).

-- Step 3: undo, so the data set stays as it was
UPDATE members SET email = NULL WHERE member_id = 19;

Updating several columns and using the previous value

An UPDATE can compute the new value from the current one:

SELECT loan_id, surcharge FROM loans WHERE loan_id = 8;   -- 4.20

UPDATE loans
SET surcharge = surcharge + 0.50
WHERE loan_id = 8;
UPDATE 1
SELECT loan_id, surcharge FROM loans WHERE loan_id = 8;
loan_id surcharge
8 4.70
-- Undo
UPDATE loans SET surcharge = 4.20 WHERE loan_id = 8;

Important: surcharge + 0.50 on a NULL gives NULL. If we had run that UPDATE without a WHERE, the four open loans would have gone from NULL to… NULL, and the other eight would have got more expensive. Silently.

A realistic case with two statements

When Marta Alsina returns EJ-3081, two tables have to be touched:

-- 1) Close the loan
UPDATE loans
SET return_date = '2026-08-01', surcharge = 0.00
WHERE loan_id = 9;

-- 2) Release the copy
UPDATE copies
SET status = 'available'
WHERE code = 'EJ-3081';
UPDATE 1
UPDATE 1

This raises an uncomfortable question: what happens if the first statement works and the second one fails? The database would be left in an inconsistent state: a closed loan and a copy marked as on loan. The answer is the transaction, and it is the content of lesson 06-01. For now, we undo:

UPDATE loans SET return_date = NULL, surcharge = NULL WHERE loan_id = 9;
UPDATE copies SET status = 'on_loan' WHERE code = 'EJ-3081';

The forgotten WHERE

-- CATASTROPHIC: sets the same email on all ten members
UPDATE members SET email = 'pau.miralles@example.org';
UPDATE 10

And it would also violate uq_members_email, so in this particular case the constraint would save us. There will not always be a constraint to save you. An UPDATE copies SET status = 'withdrawn'; would have withdrawn BiblioRed's 40,000 copies without a word of protest.

Habits that avoid disaster:

  1. Write the WHERE before the SET. Start by typing UPDATE table WHERE ... and then go back and insert the SET. It sounds odd, it works.
  2. Try it with a SELECT first. Always.
  3. Work inside a transaction for delicate operations (06-01).
  4. In psql, turn on \set ON_ERROR_STOP on in your scripts so they stop at the first error.

  1. DELETE: removing rows

DELETE FROM table WHERE condition;

Let's create a throwaway row so we do not spoil the data set:

-- Test sign-up
INSERT INTO members (member_id, first_name, last_name, email, join_date, branch_id, active)
VALUES (99, 'Bruno', 'Temporal', 'bruno.temporal@example.org', '2026-08-01', 1, TRUE);
INSERT 0 1
-- Step 1: check
SELECT member_id, first_name, last_name FROM members WHERE member_id = 99;
member_id first_name last_name
99 Bruno Temporal
-- Step 2: delete
DELETE FROM members WHERE member_id = 99;
DELETE 1

In PostgreSQL you can use RETURNING here too, to leave a record of what was swept away:

DELETE FROM members WHERE member_id = 99 RETURNING member_id, first_name, last_name;

DELETE and referential integrity

Try to delete a member who has loans:

DELETE FROM members WHERE member_id = 14;
ERROR:  update or delete on table "members" violates foreign key constraint
        "fk_loans_member" on table "loans"
DETAIL:  Key (member_id)=(14) is still referenced from table "loans".

The manager is protecting you. If it allowed the deletion, Marta Alsina's three loans would be left orphaned: pointing at a member who no longer exists. That behavior —and its alternatives, such as cascading deletion— is the entire subject of lesson 02-06.

In SQLite, if you forgot PRAGMA foreign_keys = ON, that DELETE will work and leave you three orphan rows without saying a word. It is exactly the scenario lesson 02-06 will teach you to detect and clean up.

The forgotten WHERE, definitive edition

DELETE FROM loans;
DELETE 12

Without a WHERE, DELETE empties the entire table, without asking and with no recycle bin. If it happens to you outside a transaction, the only way out is the backup (lesson 06-04). The four habits from the previous section apply here with even more reason.

  1. TRUNCATE: emptying a whole table

When what you really want is to empty a complete table, there is a dedicated statement:

TRUNCATE TABLE loans;
DELETE FROM table TRUNCATE TABLE table
Sublanguage DML DDL
Accepts WHERE Yes No
Speed on large tables Slow: deletes row by row Almost instantaneous
Logs each deleted row Yes No
Resets the identifier generator No Optionally (RESTART IDENTITY)
Can be undone with ROLLBACK Yes In PostgreSQL yes; in other managers no
-- Empty and reset the counter, dragging the dependent tables along
TRUNCATE TABLE loans, reservations RESTART IDENTITY;

SQLite has no TRUNCATE; use DELETE FROM table without a WHERE, which it optimizes internally.

Do not run any TRUNCATE on your biblioredb: you would lose the data set you have just loaded. If you already have, run the script from section 2 again.

Common Mistakes and Tips

  • UPDATE or DELETE without a WHERE. The most expensive mistake anyone makes with SQL. Always try the condition with a SELECT first.
  • Writing 'NULL' instead of NULL. With quotes it is a four-letter text string; without quotes, the absence of a value. A column that mixes both is a ruined column.
  • Using = NULL or <> NULL. Zero rows, no error. Always IS NULL / IS NOT NULL.
  • Forgetting that <> value excludes NULLs. WHERE surcharge <> 0 does not return the loans with a null surcharge. If you want them, add OR surcharge IS NULL.
  • Mixing AND and OR without parentheses. AND wins. Always add parentheses.
  • INSERT without a column list. It breaks as soon as somebody touches the schema, sometimes silently.
  • Inserting explicit identifiers and not resynchronizing the sequence. It causes duplicate-key errors much later, when nobody remembers the initial load.
  • LIMIT without ORDER BY. The result is not reproducible and pagination can repeat or skip rows.
  • Trusting that LIKE is case-sensitive. In PostgreSQL it is, in SQLite (ASCII) it is not, in MySQL it depends on the collation. If you need certainty, LOWER(column) LIKE ....
  • Tip: in psql, \pset null '(null)' makes NULLs visible and \x on shows results vertically, ideal for wide rows.
  • Tip: save all your SQL in files (biblioredb_schema.sql, biblioredb_data.sql) and run them with \i file.sql in psql or .read file.sql in sqlite3. Being able to rebuild the database in ten seconds gives you the freedom to experiment without fear.

Exercises

They are all solved on a single table. Write the query before looking at the solution and compare the results.

Exercise 1: Catalog queries

  1. The titles and publishers of the books published before 2010, from oldest to most recent.
  2. The books with no ISBN on record.
  3. The distinct publishers in the collection, in alphabetical order.
  4. The titles containing ar in any position, ignoring case.
  5. The codes of the copies at branch 2 that are not available.
  6. The three most recent books in the collection.

Exercise 2: Queries on members and loans

  1. The members who signed up in 2021 or 2022, with first and last name in a single column called member.
  2. The members who are not active.
  3. The loans returned late (the actual return date is after the due date), sorted by days late… or, if you cannot compute the difference yet, simply by loan date.
  4. The open loans that were due back before 10 August 2026.
  5. The second page of a loan listing sorted by loan date descending, with 5 loans per page.
  6. The loans whose surcharge is not zero, including those whose surcharge has not been calculated yet.

Exercise 3: Safe modification

Write the statements for each operation, preceded by the checking SELECT and followed by the statement that undoes the change.

  1. Copy EJ-3088 comes back from the workshop: set it to available.
  2. Fix the publisher of book 337: change it from "Marlia Editions" to "Marlia Editions Ltd".
  3. Sign up a new member, Berta Colomer (berta.colomer@example.org), at the South branch, with today's date (use '2026-08-02'), active, letting the manager assign the identifier. Then delete her.
  4. Reservation 4 was cancelled by mistake: reactivate it.

Solutions

Solution 1

-- 1
SELECT title, publisher, publication_year
FROM books
WHERE publication_year < 2010
ORDER BY publication_year ASC;
title publisher publication_year
Ensanche Records (1904) Vallmar City Council 1904
The Pillars of the Earth Andana Press 1989
The Map of Time Andana Press 2008
-- 2  (IS NULL, not = NULL!)
SELECT book_id, title FROM books WHERE isbn IS NULL;
book_id title
339 Ensanche Records (1904)
-- 3
SELECT DISTINCT publisher FROM books ORDER BY publisher;
publisher
Andana Press
Marlia Editions
North Technical Press
Vallmar City Council
-- 4  (ILIKE in PostgreSQL; LOWER(title) LIKE '%ar%' is the portable form)
SELECT title FROM books WHERE title ILIKE '%ar%';
title
The Pillars of the Earth
Urban Gardening Handbook

Notice that "The Pillars" gets in through Pillars and "Gardening" through Gardening: LIKE searches for substrings, not whole words.

-- 5
SELECT code, status FROM copies
WHERE branch_id = 2 AND status <> 'available';
code status
EJ-3081 on_loan
EJ-3090 on_loan
-- 6
SELECT title, publication_year FROM books ORDER BY publication_year DESC LIMIT 3;
title publication_year
Urban Gardening Handbook 2023
Winter of the Birds 2021
Algebra for the Impatient 2019

Solution 2

-- 1
SELECT first_name || ' ' || last_name AS member, join_date
FROM members
WHERE join_date BETWEEN '2021-01-01' AND '2022-12-31'
ORDER BY join_date;
member join_date
Marta Alsina 2021-03-08
Iván Pereda 2021-09-19
Nuria Bastos 2022-01-30
-- 2
SELECT member_id, first_name, last_name FROM members WHERE active = FALSE;
-- also valid: WHERE NOT active
member_id first_name last_name
13 Ramón Etxebarri
-- 3
SELECT loan_id, loan_date, due_date, return_date, surcharge
FROM loans
WHERE return_date > due_date
ORDER BY loan_date;
loan_id loan_date due_date return_date surcharge
2 2026-03-05 2026-03-26 2026-04-02 1.40
5 2026-04-12 2026-05-03 2026-05-10 1.40
8 2026-05-19 2026-06-09 2026-06-30 4.20

Note that the condition compares two columns of the same row, which is perfectly legitimate. And that the four open loans do not appear: NULL > date is UNKNOWN. In PostgreSQL, return_date - due_date would give the days late directly (7, 7 and 21).

-- 4
SELECT loan_id, member_id, due_date
FROM loans
WHERE return_date IS NULL
  AND due_date < '2026-08-10'
ORDER BY due_date;
loan_id member_id due_date
9 14 2026-08-04
10 15 2026-08-08
-- 5  Page 2 with 5 per page: OFFSET (2-1) * 5 = 5
SELECT loan_id, loan_date
FROM loans
ORDER BY loan_date DESC, loan_id DESC
LIMIT 5 OFFSET 5;
loan_id loan_date
7 2026-05-08
4 2026-04-06
3 2026-03-11
2 2026-03-05
1 2026-03-02

(The second criterion loan_id DESC guarantees that pagination is stable even if there were repeated dates.)

-- 6  The key: NULL is not "different from zero", it is "unknown"
SELECT loan_id, surcharge
FROM loans
WHERE surcharge <> 0 OR surcharge IS NULL
ORDER BY loan_id;
loan_id surcharge
2 1.40
5 1.40
8 4.20
9 (NULL)
10 (NULL)
11 (NULL)
12 (NULL)

Without the OR surcharge IS NULL you would have got only three rows and lost sight of the four loans in progress.

Solution 3

-- 1
SELECT copy_id, code, status FROM copies WHERE code = 'EJ-3088';            -- in_repair
UPDATE copies SET status = 'available' WHERE code = 'EJ-3088';              -- UPDATE 1
UPDATE copies SET status = 'in_repair' WHERE code = 'EJ-3088';              -- undo

-- 2
SELECT book_id, title, publisher FROM books WHERE book_id = 337;
UPDATE books SET publisher = 'Marlia Editions Ltd' WHERE book_id = 337;     -- UPDATE 1
UPDATE books SET publisher = 'Marlia Editions'     WHERE book_id = 337;     -- undo

-- 3  Without supplying member_id: the IDENTITY column generates it (it will be 21 if
--    you resynchronized the sequence in section 3; if not, it will raise a
--    duplicate-key error, which is precisely the lesson of that section).
INSERT INTO members (first_name, last_name, email, join_date, branch_id, active)
VALUES ('Berta', 'Colomer', 'berta.colomer@example.org', '2026-08-02', 3, TRUE)
RETURNING member_id;
--  member_id
-- -----------
--         21

SELECT member_id, first_name, last_name FROM members WHERE last_name = 'Colomer';
DELETE FROM members WHERE last_name = 'Colomer';                            -- DELETE 1

-- 4
SELECT reservation_id, status FROM reservations WHERE reservation_id = 4;   -- cancelled
UPDATE reservations SET status = 'active'    WHERE reservation_id = 4;      -- UPDATE 1
UPDATE reservations SET status = 'cancelled' WHERE reservation_id = 4;      -- undo

A final check that everything is back as it was:

SELECT COUNT(*) FROM members;   -- 10
SELECT COUNT(*) FROM loans;     -- 12
SELECT status FROM reservations WHERE reservation_id = 4;   -- cancelled

Conclusion

This lesson has taken BiblioRed from an empty schema to a live, queryable database:

  • INSERT with an explicit column list (always), with one or several rows, with NULL unquoted and with RETURNING in PostgreSQL to find out the generated identifiers. Plus the automatic checking of uniqueness and foreign keys that the spreadsheet never had.
  • The BiblioRed data set: 4 branches, 8 authors, 10 members, 9 books, 15 copies, 12 loans and 5 reservations, all fictional and consistent. It is the working material for the rest of the course.
  • The side effect of inserting keys by hand and how to resynchronize the generator with RESTART WITH or setval.
  • SELECT with column projection, AS aliases (with double quotes when they carry spaces), concatenation with || and DISTINCT applied to the complete combination of columns.
  • WHERE with comparisons, AND/OR/NOT and their precedence, BETWEEN (endpoints included), IN and NOT IN (with its NULL trap), LIKE/ILIKE with % and _, and IS NULL, which is the only way to ask about absence.
  • ORDER BY with several criteria, ASC/DESC, NULLS FIRST/NULLS LAST and the importance of a tie-breaking criterion.
  • LIMIT/OFFSET for pagination, always with ORDER BY.
  • UPDATE and DELETE with the discipline of a prior SELECT, reading the number of affected rows and the respect that a forgotten WHERE demands; plus TRUNCATE as a fast DDL-level emptying.

So far, every query has looked at a single table, and that leaves questions unanswered: we do not know who has EJ-3081, nor which title is the most borrowed, nor which members have never taken anything out. The data is spread on purpose —that is the essence of the relational model— and now it is time to reassemble it.

In lesson 02-04, Multi-Table Queries: JOINs and Subqueries, you will learn to join members with loans, loans with copies, copies with books and books with authors, all in a single query; to use LEFT JOIN to find what has no match; and to nest queries inside queries. That is where SQL starts to become truly powerful.

© Copyright 2026. All rights reserved