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
INSERT: adding rows- Deliverable: the BiblioRed data set
- Resetting the identifier generators
SELECT: projection, aliases andDISTINCTWHERE: filtering rowsORDER BY: sorting the resultLIMITandOFFSET: paginationUPDATE: modifying existing rowsDELETE: removing rowsTRUNCATE: emptying a whole table- Common mistakes and tips
- Exercises
- Conclusion
INSERT: adding rows
INSERT: adding rowsBasic form
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');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 isGENERATED 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 toDATEbecause 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');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;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:
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.
- 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,loansandreservationswe let the manager generate them. - In
membersandbookswe 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:
| 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:
TRUE/FALSEinmembers.active→1/0.- The
ALTER TABLE ... RESTART WITHlines from step 0 do not exist in SQLite: remove them. WithINTEGER PRIMARY KEYand noAUTOINCREMENT, 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 ofYYYY-MM-DDmatches the chronological one. That is exactly why that format is the right one.
- 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;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:
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.
SELECT: projection, aliases and DISTINCT
SELECT: projection, aliases and DISTINCTSELECT is SQL's most used statement and the one that takes up the most lessons in this course. Its minimal form:
Projection: choosing columns
| 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
| 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:
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:
ASis optional (code labelworks 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:
| 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
It returns 15 rows with a lot of repetition. With DISTINCT:
| status |
|---|
| available |
| in_repair |
| on_loan |
| withdrawn |
DISTINCT applies to the complete combination of the selected columns, not to the first one:
| 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".
WHERE: filtering rows
WHERE: filtering rowsWHERE 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 |
| 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
| 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
| 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:
Same result as before.
Important warning about
NOT INandNULL: if the list contains aNULL,NOT INreturns no rows at all, because of the three-valued logic from lesson 02-01.x NOT IN (1, 2, NULL)is equivalent tox <> 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 |
| title |
|---|
| The Pillars of the Earth |
| Algebra for the Impatient |
| Winter of the Birds |
LIKE is case-sensitive in PostgreSQL:
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:
| title |
|---|
| The Map of Time |
| The Pillars of the Earth |
A portable alternative, valid in SQLite too:
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 _:
| 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;| 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 missingNeither 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.
ORDER BY: sorting the result
ORDER BY: sorting the resultBecause a relation is a set and has no order, ORDER BY is the only way to guarantee one.
| 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:
| 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:
| 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_nameSorting 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.
LIMIT and OFFSET: pagination
LIMIT and OFFSET: pagination| title |
|---|
| Algebra for the Impatient |
| Delta Trails |
| Ensanche Records (1904) |
OFFSET skips rows before it starts counting:
| 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:
LIMITwithoutORDER BYmakes 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.- A large
OFFSETis 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.
UPDATE: modifying existing rows
UPDATE: modifying existing rowsBefore 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 | |
|---|---|---|---|
| 19 | Pau | Miralles | (NULL) |
-- Step 2: now yes, modify
UPDATE members SET email = 'pau.miralles@example.org' WHERE member_id = 19;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;| loan_id | surcharge |
|---|---|
| 8 | 4.70 |
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';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';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:
- Write the
WHEREbefore theSET. Start by typingUPDATE table WHERE ...and then go back and insert theSET. It sounds odd, it works. - Try it with a
SELECTfirst. Always. - Work inside a transaction for delicate operations (06-01).
- In
psql, turn on\set ON_ERROR_STOP onin your scripts so they stop at the first error.
DELETE: removing rows
DELETE: removing rowsLet'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);| member_id | first_name | last_name |
|---|---|---|
| 99 | Bruno | Temporal |
In PostgreSQL you can use RETURNING here too, to leave a record of what was swept away:
DELETE and referential integrity
Try to delete a member who has loans:
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
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.
TRUNCATE: emptying a whole table
TRUNCATE: emptying a whole tableWhen what you really want is to empty a complete table, there is a dedicated statement:
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
TRUNCATEon yourbiblioredb: 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
UPDATEorDELETEwithout aWHERE. The most expensive mistake anyone makes with SQL. Always try the condition with aSELECTfirst.- Writing
'NULL'instead ofNULL. 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
= NULLor<> NULL. Zero rows, no error. AlwaysIS NULL/IS NOT NULL. - Forgetting that
<> valueexcludesNULLs.WHERE surcharge <> 0does not return the loans with a null surcharge. If you want them, addOR surcharge IS NULL. - Mixing
ANDandORwithout parentheses.ANDwins. Always add parentheses. INSERTwithout 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.
LIMITwithoutORDER BY. The result is not reproducible and pagination can repeat or skip rows.- Trusting that
LIKEis 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)'makesNULLs visible and\x onshows 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.sqlinpsqlor.read file.sqlinsqlite3. 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
- The titles and publishers of the books published before 2010, from oldest to most recent.
- The books with no ISBN on record.
- The distinct publishers in the collection, in alphabetical order.
- The titles containing
arin any position, ignoring case. - The codes of the copies at branch 2 that are not available.
- The three most recent books in the collection.
Exercise 2: Queries on members and loans
- The members who signed up in 2021 or 2022, with first and last name in a single column called
member. - The members who are not active.
- 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.
- The open loans that were due back before 10 August 2026.
- The second page of a loan listing sorted by loan date descending, with 5 loans per page.
- 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.
- Copy
EJ-3088comes back from the workshop: set it toavailable. - Fix the publisher of book 337: change it from "Marlia Editions" to "Marlia Editions Ltd".
- 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. - 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 |
| book_id | title |
|---|---|
| 339 | Ensanche Records (1904) |
| 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.
| code | status |
|---|---|
| EJ-3081 | on_loan |
| EJ-3090 | on_loan |
| 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; -- undoA 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; -- cancelledConclusion
This lesson has taken BiblioRed from an empty schema to a live, queryable database:
INSERTwith an explicit column list (always), with one or several rows, withNULLunquoted and withRETURNINGin 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 WITHorsetval. SELECTwith column projection,ASaliases (with double quotes when they carry spaces), concatenation with||andDISTINCTapplied to the complete combination of columns.WHEREwith comparisons,AND/OR/NOTand their precedence,BETWEEN(endpoints included),INandNOT IN(with itsNULLtrap),LIKE/ILIKEwith%and_, andIS NULL, which is the only way to ask about absence.ORDER BYwith several criteria,ASC/DESC,NULLS FIRST/NULLS LASTand the importance of a tie-breaking criterion.LIMIT/OFFSETfor pagination, always withORDER BY.UPDATEandDELETEwith the discipline of a priorSELECT, reading the number of affected rows and the respect that a forgottenWHEREdemands; plusTRUNCATEas 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.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
