Skip to content
Ameen Abozaid
PublishedBEG-050-L08

Demo: answering library questions with SQL

  • Lesson 8 of 9
  • 11 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

You now know every statement this module teaches. Knowing the tools is not the same as using them: a real question arrives in plain language ("who is late returning books?"), and you have to turn it into a query. This is a demo lesson: we walk through the translation step by step, make one planned mistake on purpose, and fix it. It prepares you for the module lab, 25 graded queries against a seeded library database.

In the library picture, the librarian does not hand you a query. A member walks up and asks a question, and the skill is hearing the question in the structure of the tables.

The seeded library used here is the one from earlier lessons: 5 authors, 11 books, 5 members, 8 loans. If you can, follow along in your own PostgreSQL client with the schema and data from Lesson 2 and the module's seed data, and run each query yourself. Menus and commands differ between tools, so use whatever your course setup provides.

This lesson builds on Lessons 1 to 7.

Learning Objectives

By the end of this lesson you will be able to:

  • Turn a plain-language question into a query using a repeatable method.
  • Choose the right clauses (WHERE, GROUP BY, HAVING, JOIN) from the wording of the question.
  • Recognize the error message for an aggregate in WHERE, and fix it.
  • Compute "overdue" from a due date and a missing return date.
  • Check a result for plausibility against the data.
  • Build a query in small, tested steps.

Written Explanation

A method for turning questions into queries

Work through these five questions in order, writing a small query at each step:

  1. What is one row of the answer? "A book", "a member", "a loan", "an author with a number". This decides the main table, and whether you need GROUP BY.
  2. Which tables hold the data? List them, and the key that connects each pair (foreign key = primary key).
  3. Which rows should be kept? Conditions on single rows go in WHERE.
  4. Is there a summary? "How many", "most", "average" means an aggregate; "for each" means GROUP BY; a condition on the summary goes in HAVING.
  5. In what order, and how many? ORDER BY, and LIMIT for "top n".

Then build in small steps: start with the table that holds the row you need, add one join, run it, add the next condition, run again. Never write the whole thing and then try to debug it.

Reading the wording

Words in the questionUsually means
"each", "per", "for every"GROUP BY that thing
"how many", "total", "average", "most"aggregate function
"with at least N", "more than N" (about a count)HAVING COUNT(*) ...
"published after", "joined in 2024" (about one row)WHERE
"who have never", "with no"left join + IS NULL, or NOT IN
"including those without", "all X and their Y"left join from X
"not yet returned"returned_on IS NULL

"Overdue" is a definition, not a column

The schema has no overdue column, and should not: it would go stale every night. A loan is overdue when it has not been returned (returned_on IS NULL) and its due date has passed (due_on < today). In PostgreSQL, CURRENT_DATE is today's date, and subtracting one date from another gives a number of days. For the demo, we use a fixed date, DATE '2025-11-05', so everyone gets identical results.

When a question is ambiguous

Plain-language questions leave things out: "most borrowed author": by number of loans, or by number of books? "Late": still out, or returned after the due date? Decide, say what you decided, and write it in a comment. A confident answer to the wrong question is the commonest way to lose marks in the lab.

Examples

All results are real output from PostgreSQL 18.3 on the seeded library.

Example 1: a simple filter, then a grouped count (simple)

Question: "Which books were published after 2000? Sort by title."

One row is a book, one table, one row-level condition, one sort:

sql
SELECT title, published_year
FROM books
WHERE published_year > 2000
ORDER BY title;
text
   title   | published_year 
-----------+----------------
 Fledgling |           2005
 Lavinia   |           2008
(2 rows)

Question: "How many loans has each member made?" One row is a member with a number, so group by member. The names live in members, the loans in loans, joined by loans.member_id = members.id:

sql
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
ORDER BY loans DESC, m.name;
text
 name  | loans 
-------+-------
 Amal  |     3
 Bilal |     2
 Carla |     2
 Dina  |     1
(4 rows)

Sanity check: 3 + 2 + 2 + 1 = 8, the number of loans in the table. Emil is missing, because he has no loans and this is an inner join. That is acceptable for this question ("how many loans has each member made"), but not for "every member with their count", which would need a left join and COUNT(l.id).

Example 2: the planned mistake (practical)

Question: "Which members have made at least two loans?" The tempting version puts the condition on the count in WHERE:

sql
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
WHERE COUNT(*) >= 2
GROUP BY m.id, m.name;
text
ERROR:  aggregate functions are not allowed in WHERE

Read the message. It says what the lesson on HAVING said: WHERE runs before the groups exist. "At least two loans" is a statement about a group, so it belongs in HAVING:

sql
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
HAVING COUNT(*) >= 2
ORDER BY loans DESC, m.name;
text
 name  | loans 
-------+-------
 Amal  |     3
 Bilal |     2
 Carla |     2
(3 rows)

Dina, with one loan, is gone. Notice how little changed: one clause moved. This is a good rule for debugging: the error message, read slowly, usually tells you the fix.

Example 3: overdue loans and the most-borrowed authors (realistic)

Question: "Which loans are overdue, who has them, and how late are they?" Using the definition above (not returned, and due date before "today", here 5 November 2025). One row is a loan, and we want names from two other tables, so three tables are joined through the junction loans:

sql
SELECT m.name, b.title, l.due_on,
       DATE '2025-11-05' - l.due_on AS days_late
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books   b ON b.id = l.book_id
WHERE l.returned_on IS NULL
  AND l.due_on < DATE '2025-11-05'
ORDER BY l.due_on;
text
 name  |        title         |   due_on   | days_late 
-------+----------------------+------------+-----------
 Amal  | A Wizard of Earthsea | 2025-09-15 |        51
 Bilal | Kindred              | 2025-10-04 |        32
 Carla | The Dispossessed     | 2025-10-24 |        12
 Dina  | Palace Walk          | 2025-11-03 |         2
(4 rows)

All four open loans are overdue on that date. Subtracting two dates giving a number of days is PostgreSQL behaviour; other databases differ. In real use you would replace the fixed date with CURRENT_DATE, which gives different rows on different days.

Question: "Which authors are borrowed most?" Decision: count loans, not books. One row is an author with a number. The chain is loans to books to authors:

sql
SELECT a.name, COUNT(*) AS times_borrowed
FROM loans l
JOIN books   b ON b.id = l.book_id
JOIN authors a ON a.id = b.author_id
GROUP BY a.id, a.name
ORDER BY times_borrowed DESC, a.name;
text
      name      | times_borrowed 
----------------+----------------
 Ursula Le Guin |              4
 Octavia Butler |              2
 Chinua Achebe  |              1
 Naguib Mahfouz |              1
(4 rows)

Check: 4 + 2 + 1 + 1 = 8 loans. The anonymous poet has no loans and is not listed, which is fine for "most borrowed". For "top three", add LIMIT 3 after the ORDER BY; the name in the sort breaks ties, so the result is stable.

Backend Insight

This translation skill is most of what "writing the data layer" means. A product manager asks for "customers who haven't ordered in 90 days", and you find the row, the tables, the conditions, the summary. The five-question method is also how you would explain a query to a colleague in code review.

Two habits from the demo carry over. First, check totals: the loan counts add up to the table's row count, an overdue list should be a subset of the open loans. Cheap sanity checks catch most silent errors, including inner joins that drop rows. Second, write down the definition of business words like "overdue", "active", or "top": the SQL is only as right as the definition, and the definition belongs in a comment or a ticket, not in someone's head.

Common Mistakes

Mistake 1: Writing the whole query at once

Write the one-table version first, run it, then add a join and run again. When something breaks, you know which step broke it.

Mistake 2: Filtering on an aggregate in WHERE

You saw the error in Example 2. Row condition: WHERE. Group condition: HAVING.

Mistake 3: Choosing an inner join when "including those without" is in the question

The words "all", "including", and "even if" point to a left join.

Mistake 4: Answering a different question than asked

"Most borrowed" by books or by loans? Decide and note it. Check with a total that adds up.

Mistake 5: Computing "overdue" without checking returned_on

Comparing only due_on to today flags loans that were returned on time long ago. A loan is overdue only while it is not returned.

Exercises

Try each exercise yourself before opening the solutions. Use the seeded library.

Level A: Foundation

A1. Write a query for the titles of books by author 4, newest first.

Show solution A1

A1. SELECT title FROM books WHERE author_id = 4 ORDER BY published_year DESC;

A2. Write a query counting how many loans are not yet returned.

Show solution A2

A2. SELECT COUNT(*) FROM loans WHERE returned_on IS NULL; returns 4 on the seeded data.

A3. In your own words: what are the two conditions that make a loan overdue?

Show solution A3

A3. It has not been returned (returned_on IS NULL), and its due date is before today (due_on < CURRENT_DATE).

Level B: Application

B1. Which members have borrowed at least one book published before 1970? (Join through loans and books.) Hint: use DISTINCT after SELECT so a member appears once. This is a preview, as DISTINCT was not covered.

Show solution B1

B1.

sql
SELECT DISTINCT m.name
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE b.published_year < 1970;

On the seeded data: Amal (books 2 and 3), Carla (book 4), and Dina (book 6). Without DISTINCT, Amal would appear twice.

B2. List each author's name with the number of books they have, only for authors with at least two books, most books first.

Show solution B2

B2.

sql
SELECT a.name, COUNT(*) AS books
FROM authors a
JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name
HAVING COUNT(*) >= 2
ORDER BY books DESC;

Three rows: Ursula Le Guin (4), Octavia Butler (3), Chinua Achebe (2).

B3. List loans that were returned late (after due_on), with member name and book title.

Show solution B3

B3.

sql
SELECT m.name, b.title, l.due_on, l.returned_on
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE l.returned_on > l.due_on;

On the seeded data, one row: Carla returned Things Fall Apart four days late. Loans with a NULL return date are excluded by the comparison, which is right: they are not returned at all.

Level C: Challenge

C1. "Show every member with the number of loans still open, including members with none (showing 0)." Write the query and say what you checked.

Show solution C1

C1.

sql
SELECT m.name, COUNT(l.id) AS open_loans
FROM members m
LEFT JOIN loans l ON l.member_id = m.id AND l.returned_on IS NULL
GROUP BY m.id, m.name
ORDER BY m.id;

The condition on returned_on is in ON, so members without open loans remain. COUNT(l.id) gives 0 for them. Check: five rows, one per member; the open counts add up to 4, the number of open loans.

C2. A colleague's query for "the average number of loans per member" is SELECT AVG(loans) FROM (SELECT COUNT(*) AS loans FROM loans GROUP BY member_id) per_member;. It returns 2.0000000000000000. Explain what is being averaged and why the result may not answer "per member" for the whole membership. (A subquery in FROM is a preview.)

Show solution C2

C2. The inner query counts loans per member_id in loans, so only members who have at least one loan appear (four of them: 3, 2, 2, 1). The average of those four numbers is 2. Emil, with no loans, is not in the list, so the "per member" average over all five members would be 8 / 5 = 1.6. The lesson: a GROUP BY over a table only sees groups that exist. A left join from members would include everyone.

Mini Challenge

Answer these three questions with queries, using the five-question method. For each, write (a) what one row of the answer is, (b) the tables, and (c) one sanity check.

  1. "Which members have a book out that is more than 14 days overdue as of 5 November 2025?"
  2. "How many books has each author, including authors whose books were never borrowed?"
  3. "Which authors have never had a book borrowed?"

Check your work: query 1 combines returned_on IS NULL with a date comparison (due_on < DATE '2025-10-22', or a date subtraction); query 2 is a plain count of books; query 3 uses LEFT JOIN or NOT IN through books and loans and returns the anonymous poet on the seeded data.

Summary & Next Lesson

Key takeaways

  • Translate questions with five steps: the row of the answer, the tables, the row conditions, the summary, the order.
  • "Each" means GROUP BY; a condition on a count means HAVING; "including those without" means a left join.
  • Aggregates in WHERE give aggregate functions are not allowed in WHERE; move them to HAVING.
  • "Overdue" means not returned and past due.
  • Build in small steps and check totals.

Next lesson

You have seen joins used correctly. The next lesson, "Demo: joins that silently drop rows", shows what happens when an inner join is the wrong choice, and gives you a way to detect it.

Self-check

  1. What are the five questions in the method?
  2. Which clause holds COUNT(*) >= 2?
  3. How is "overdue" defined for a loan?
  4. Which join do the words "including those without" suggest?
  5. What cheap check catches many wrong results?
Show answers
  1. What one answer row is; which tables; which row conditions; whether there is a summary; the order and number of rows.
  2. HAVING.
  3. Not returned (returned_on IS NULL) and due date before today.
  4. A left join from the table whose rows must all appear.
  5. Comparing totals: for example, whether the counts add up to the table's row count.

References

  • PostgreSQL documentation, Table Expressions: GROUP BY and HAVING. Supports the WHERE-before-grouping and HAVING-after-grouping rule used in Example 2.
  • PostgreSQL documentation, Joined Tables. Supports the inner and left join behaviour used throughout.
  • The five-question method, the wording table, and the definition of "overdue" are this course's own teaching framing. All query results and error messages are real output from PostgreSQL 18.3. Date subtraction returning days was observed in a run and is not tied to a fetched page.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.