🗄️Databases
Foundations · Lesson 1/8

Relational Model & SQL Basics

40 min 1 quiz Graded labs: 3

The library that never loses a card 📚

Picture a big library with 100,000 books and no catalogue. Someone asks: "Which books were published after 1990, best-rated first?" You would have to walk every shelf.

Now give the librarian a card catalogue. Every kind of thing gets its own drawer (books, members, loans). Every drawer holds identical cards, and every card has the same fields: title, author, year. Each card also has a unique number, so "card 42 in the members drawer" always means the same person.

That is a relational database:

  • a table is a drawer (books)
  • a row is one card (one book)
  • a column is one field on the card (title, year)
  • a primary key is the unique number on the card
  • SQL (Structured Query Language) is how you ask the librarian questions

What you'll learn

  • How tables, rows, columns, primary keys and foreign keys fit together
  • The everyday verbs: SELECT, INSERT, UPDATE, DELETE
  • Filtering and sorting with WHERE, ORDER BY and LIMIT
  • NULL, the "unknown" value that behaves unlike any other
  • Constraints that make the database refuse bad data for you

In the labs, the database is SQLite (it ships inside Python's standard library). The program creates a tiny in-memory database, and you write the SQL inside a Python string.

Tables, keys and types

A table has a fixed list of columns, each with a name and a type (INTEGER, TEXT, REAL, ...). Every row supplies one value per column.

CREATE TABLE books (
  id     INTEGER PRIMARY KEY,
  title  TEXT NOT NULL,
  author TEXT NOT NULL,
  year   INTEGER NOT NULL,
  rating REAL
);

INSERT INTO books (title, author, year, rating)
VALUES ('Dune', 'Frank Herbert', 1965, 4.5);
  • The primary key identifies exactly one row. Other tables point at a row by storing its key.
  • A foreign key is a column whose value must match a key in another table. loans.book_id should always match some books.id. This is what makes the model relational: tables are linked by values, not by copying data.
  • The order of rows in a table is not part of the model. If you want a specific order, you must ask for it.

SQLite is flexible about column types (it treats them as hints). Other databases such as PostgreSQL are strict. Declare the types you mean anyway; it documents your intent and keeps your SQL portable.

Asking questions: SELECT

SELECT title, year          -- which columns to show
FROM books                  -- which table
WHERE year >= 1980          -- keep only matching rows
ORDER BY year DESC, title   -- sort (DESC = high to low)
LIMIT 5;                    -- stop after 5 rows

You write the clauses in this order, but think of the database working in a different order: first FROM (pick the table), then WHERE (drop rows), then SELECT (choose columns), then ORDER BY, then LIMIT. Run the demo below to see that pipeline written as plain Python.

Every SQL clause is one transformation of a list of rows. Notice rating is not None: a missing rating is not a number, so it needs a special test. SQL has the same issue with NULL.

# The same query as plain Python, one step per SQL clause:
# SELECT title, rating FROM books
# WHERE year >= 1980 AND rating IS NOT NULL
# ORDER BY rating DESC LIMIT 3
books = [
    {"title": "Dune", "year": 1965, "rating": 4.5},
    {"title": "Neuromancer", "year": 1984, "rating": 4.0},
    {"title": "Beloved", "year": 1987, "rating": 4.2},
    {"title": "Snow Crash", "year": 1992, "rating": 4.1},
    {"title": "Cloud Atlas", "year": 2004, "rating": 3.9},
    {"title": "Piranesi", "year": 2020, "rating": None},  # not rated yet
]

rows = books                                                       # FROM books
rows = [b for b in rows if b["year"] >= 1980 and b["rating"] is not None]  # WHERE
rows = [(b["title"], b["rating"]) for b in rows]                   # SELECT title, rating
rows.sort(key=lambda r: r[1], reverse=True)                        # ORDER BY rating DESC
rows = rows[:3]                                                    # LIMIT 3

for title, rating in rows:
    print(title, rating)

Filtering well: WHERE, NULL and parameters

Inside WHERE you can combine tests with AND, OR, NOT, and use =, <>, <, >=, BETWEEN a AND b, IN (...) and LIKE 'Du%' (% means "anything").

NULL means "unknown", not zero and not empty text. Any comparison with an unknown is unknown, and WHERE keeps a row only when the test is true:

SELECT * FROM books WHERE rating = NULL;    -- always returns nothing!
SELECT * FROM books WHERE rating IS NULL;   -- correct
SELECT * FROM books WHERE rating < 4;       -- also skips rows where rating is NULL

Never glue user input into SQL text. Pass values separately as parameters (? or :name), and the database treats them as data, never as SQL. This is the foundation of protection against SQL injection, which a later lesson covers:

db.execute("SELECT title FROM books WHERE year >= ?", (1990,))
db.execute("SELECT title FROM books WHERE year >= :y LIMIT :n", {"y": 1990, "n": 5})

Sorting. ORDER BY takes several keys. ORDER BY rating DESC, title sorts by rating, and breaks ties alphabetically. Without ORDER BY, the order of the result is not guaranteed, even if it looks stable today. LIMIT without ORDER BY gives you "some rows".

Your turn: query the library

In the lab below, all the plumbing is written. You only write the SELECT.

Lab exercise 1

PythonGraded in Code Lab

The club library is seeded in the books table (title, author, year, rating). Some books have no rating yet (rating is NULL).

Write the query that returns the title, year and rating of the books that were published in or after :min_year and have a rating of at least 4.0 (unrated books are left out). Show the best-rated first, break ties by title A to Z, and return at most :max_rows rows.

Input: one line with two integers: min_year max_rows. Output: one line per row, title | year | rating, or (no books) when nothing matches.

Example: input 1990 3 gives

The Road | 2006 | 4.2
Kafka on the Shore | 2002 | 4.1
Snow Crash | 1992 | 4.1
Visible tests (5)
  • Modern books, top 3
  • Ties are broken by title
  • Only unrated or low-rated books match: no rows
  • A limit larger than the result returns every match
  • LIMIT 0 returns nothing
Example run
Input
1990 3
Expected output
The Road | 2006 | 4.2
Kafka on the Shore | 2002 | 4.1
Snow Crash | 1992 | 4.1
Starter code
import sqlite3
import sys

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, year INTEGER NOT NULL, rating REAL)")
db.executemany(
    "INSERT INTO books (title, author, year, rating) VALUES (?, ?, ?, ?)",
    [
        ("Dune", "Frank Herbert", 1965, 4.5),
        ("Palace Walk", "Naguib Mahfouz", 1956, 4.4),
        ("Season of Migration to the North", "Tayeb Salih", 1966, 4.3),
        ("The Left Hand of Darkness", "Ursula K. Le Guin", 1969, 4.2),

More lines in Code Lab: 26

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Changing data: INSERT, UPDATE, DELETE

INSERT INTO products (name, stock, price_cents) VALUES ('Desk Lamp', 5, 2499);

UPDATE products SET stock = stock + 10 WHERE name = 'Stapler';

DELETE FROM products WHERE discontinued = 1;

Together with SELECT these four verbs are called CRUD (Create, Read, Update, Delete).

The most expensive typo in SQL: UPDATE or DELETE without a WHERE changes every row. Habit to build: write the WHERE first, test it as a SELECT, and only then turn it into an UPDATE or DELETE.

Two details worth knowing:

  • In SET stock = stock + 10 the right side is computed from the current value of the row. The database does the arithmetic, so you do not read the value into your program first.
  • Every statement reports how many rows it touched (cursor.rowcount in Python's sqlite3). Checking it is a cheap way to notice a WHERE that matched nothing, or too much.

Money is stored here as integer cents (price_cents), not as REAL. Binary floating point cannot represent values like 0.10 exactly, and rounding errors in money are a classic bug.

Your turn: run a stock-take

Lab exercise 2

PythonGraded in Code Lab

The products table has id, name, stock, price_cents and discontinued (1 = no longer sold, default 0). Write three statements:

  1. restock_sql: add :qty to the stock of every product whose stock is below :threshold and which is not discontinued.
  2. delete_sql: delete all discontinued products.
  3. insert_sql: add a new product named Desk Lamp with stock 5 and price 2499 cents (let the database choose the id; discontinued takes its default).

Input: one line: threshold qty. Output: restocked: N, deleted: N, inserted: N (the row counts), then one line per remaining product ordered by id. The program prints them for you.

Example: input 10 20 restocks 4 products, deletes 2, inserts 1.

Visible tests (4)
  • Restock products below 10 by 20
  • Threshold 0: nothing is below it
  • Boundary: stock equal to the threshold is not restocked
  • Huge threshold: every active product, no discontinued ones
Example run
Input
10 20
Expected output
restocked: 4
deleted: 2
inserted: 1
1 Notebook A5 stock=12 price=4.50
2 Gel Pen Black stock=23 price=1.75
3 Stapler stock=20 price=12.00
4 Whiteboard Marker stock=25 price=2.20
6 Sticky Notes stock=28 price=3.00
8 Highlighter Set stock=22 price=6.50
9 Desk Lamp stock=5 price=24.99
Starter code
import sqlite3
import sys

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, stock INTEGER NOT NULL, price_cents INTEGER NOT NULL, discontinued INTEGER NOT NULL DEFAULT 0)")
db.executemany(
    "INSERT INTO products (name, stock, price_cents, discontinued) VALUES (?, ?, ?, ?)",
    [
        ("Notebook A5", 12, 450, 0),
        ("Gel Pen Black", 3, 175, 0),
        ("Stapler", 0, 1200, 0),
        ("Whiteboard Marker", 25, 220, 0),

More lines in Code Lab: 21

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Constraints: let the database say no

Validation in your application code is a good first line of defense, but it can be bypassed or forgotten: another service, a script, a bug. Constraints live in the database and apply to every writer.

CREATE TABLE students (
  id    INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,                       -- required and no duplicates
  age   INTEGER CHECK (age BETWEEN 16 AND 100),     -- any condition you can express
  team  TEXT DEFAULT 'unassigned'                   -- used when the column is omitted
);
  • NOT NULL: the value may not be missing.
  • UNIQUE: no two rows share this value. (NULLs do not count as equal to each other, so most databases allow several NULLs in a UNIQUE column.)
  • CHECK (condition): reject any row where the condition is false.
  • PRIMARY KEY: identifies each row. It must be unique and is how other tables point at the row.
  • FOREIGN KEY: team_id INTEGER REFERENCES teams(id) means the value must exist in teams. SQLite only enforces foreign keys after PRAGMA foreign_keys = ON, once per connection. Other databases enforce them always.

When a statement breaks a constraint, the whole statement fails with an error (Python raises sqlite3.IntegrityError) and nothing is written. Your program can catch it and report something useful to the user.

Your turn: design the guard

Lab exercise 3

PythonGraded in Code Lab

Create the students table so that the database itself rejects bad rows. It needs id INTEGER PRIMARY KEY, email and age, and these rules:

  • email is required and must be unique
  • age must be between 16 and 100 inclusive

The program then tries to insert one student per input line and reports OK or REJECTED with the kind of constraint that failed.

Input: lines of email age. The word NULL instead of an email means "no email". Output: per line OK email or REJECTED email - KIND (UNIQUE, NOT NULL or CHECK), then rows: N.

Example: the lines amal@mail.com 20 and amal@mail.com 22 print

OK amal@mail.com
REJECTED amal@mail.com - UNIQUE
rows: 1
Visible tests (5)
  • Two valid students
  • Duplicate email is rejected
  • Missing email is rejected
  • Age boundaries: 15 and 101 fail, 16 and 100 pass
  • Empty input inserts nothing
Example run
Input
amal@mail.com 20
bilal@mail.com 31
Expected output
OK amal@mail.com
OK bilal@mail.com
rows: 2
Starter code
import sqlite3
import sys

db = sqlite3.connect(":memory:")

# TODO: add the constraints: email NOT NULL + UNIQUE, age CHECK between 16 and 100.
db.execute("""
    CREATE TABLE students (
        id    INTEGER PRIMARY KEY,
        email TEXT,
        age   INTEGER
    )

More lines in Code Lab: 14

Try in Code Lab

Hints and instant feedback are in Code Lab. Optional practice: it does not block finishing the lesson.

JavaScript and Python also run in your browser without an account; other languages use the secure server runner and need a free account.

Quick quiz

Question 1 of 5

What does a PRIMARY KEY guarantee?

Question 2 of 5

You run SELECT title FROM books LIMIT 3 without an ORDER BY. Which rows do you get?

Question 3 of 5

Some books have rating = NULL. What does SELECT * FROM books WHERE rating = NULL return?

Question 4 of 5

You run UPDATE products SET price_cents = 0; (no WHERE). What happens?

Question 5 of 5

What does a FOREIGN KEY constraint enforce?

Recap and what to practice next

  • A relational database stores data in tables of rows and columns; primary keys identify rows and foreign keys link tables.
  • SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT reads data; INSERT, UPDATE and DELETE change it. Always scope changes with WHERE.
  • NULL is unknown: use IS NULL, and remember that comparisons with it never come out true.
  • Pass values as parameters, never by gluing strings.
  • Constraints (NOT NULL, UNIQUE, CHECK, PRIMARY KEY, FOREIGN KEY) keep bad data out no matter who writes it.

Practice next: invent a small domain you know (a gym, a football league, a recipe box). Write its CREATE TABLE statements with constraints, insert ten rows and ask five different questions with SELECT. In the next lesson you will learn how to decide which tables a domain needs.

Practice

Reading is not learning until you run something. Pick one.

Graded lab exercises

Open an example in Code Lab