Relational Databases and SQL

EE 547 - Unit 4

Dr. Brandon Franzke

Fall 2026

Outline

Model and Language

Persistent Structured Storage

  • Durability, shared access, and structure
  • Declarative queries

The Relational Model

  • Tables, types, keys, and constraints
  • NULL and three-valued logic

SQL

  • The language and the engines
  • SELECT, JOIN, aggregation
  • Subqueries, CTEs, window functions

Design and Operations

Schema Design

  • ER modeling, cardinality, and participation

Normalization

  • Redundancy, anomalies, and normal forms
  • Denormalization

Transactions

  • ACID, isolation levels, and locking

Indexes

  • B-trees, query plans, and write costs

Scaling

  • Connections and pooling
  • Replicas and sharding

Persistent Structured Storage

Application Data Outlives Any One Process

Every running process holds state in memory. Every restart clears it.

What a restart clears

  • Python dictionaries, objects, and caches live in the process heap and go with the process
  • A container’s writable layer is replaced when the container is replaced
  • An instance’s local disk is gone when the instance terminates

What must outlive the process

  • The records the application manages
  • The results it has accumulated
  • The state its instances share

Where it lives instead

  • In a store with its own lifetime, reached over the network
  • The process that wrote a record and the process that reads it are usually different processes, often on different machines

Shared Data Is How Instances Coordinate

Instances of one application share nothing but the network and the store. The store is what makes them one application.

Requests land on different instances

  • An account created through instance 1 is looked up through instance 3
  • A retry after a timeout lands on a different instance than the first attempt
  • A background worker takes its jobs from what the web tier wrote

One seat map for twenty servers

  • A seat sold through one server is unavailable through the other nineteen, at once
  • That fact has exactly one place to live

A private copy per instance fails

  • Copies diverge at the first concurrent write
  • Deciding which copy is right is a coordination problem the network cannot settle: messages are lost, delayed, and reordered

Coordination happens at the store

  • Every read sees what other instances wrote; every write is ordered against the other writes to the same record
  • The store must keep the data through failures, give it a shape every instance agrees on, and order the writes to one record

Committed Writes Survive Process, Container, and Host

A persistent store must keep a write through the failure of whatever made it. A file write that returned is a weaker guarantee.

Process crash

  • The application segfaults mid-operation, or kill -9 ends it without cleanup
  • Completed writes are present when the replacement process starts

Container restart

  • A deployment replaces the container running the old code
  • Data written by the old version is read by the new one, with no migration step

Host failure

  • The server loses power with writes still buffered in memory
  • After recovery, every committed write is intact and every uncommitted one is gone

Write-ahead log (WAL)

  • A file written with flush() is not on disk; the operating system buffers it
  • The database appends each change to a log and forces the log to disk (fsync) before it acknowledges the commit
  • After a crash, recovery replays the log; the table pages catch up from it

Durability Has a Cost

Each level of durability is a mechanism, and each mechanism adds a wait before the write is acknowledged.

What each level waits for

  • Process memory: nothing is written
  • Local SSD: the log entry is on this host’s disk
  • Replicated storage: a second host has the entry
  • Cross-region replica: a second region has the entry

Six orders of magnitude end to end

  • Process memory to cross-region replica: ~100 ns to ~100 ms
  • A write that costs nanoseconds locally costs hundreds of milliseconds to survive a regional outage

Unstructured Storage Forces Full Scans

An application that keeps flight records as flat files, one JSON object per flight or a single CSV, reads and parses the entire dataset to answer any question about it.

File and object storage retrieve by name

# S3: retrieve a known object
s3.get_object(Bucket='flights', Key='2026/02/lax-jfk.json')

# Filesystem: read a known file
open('/data/flights/2026/02/lax-jfk.json')
  • The application must already know which object it wants
  • There is no request for “the objects matching a condition”

“Which flights from LAX have seats?”

  • Download every flight object
  • Parse each one (JSON decode, CSV parse)
  • Filter in application code
  • 10 million records at 1 KB each: 10 GB transferred and parsed

The cost scales with the size of the dataset, however selective the question.

Structured Storage Holds the Data’s Shape

The store keeps column names, types, and constraints alongside the rows: a schema. A question about the data is answered from the schema rather than by reading everything.

SELECT flight_number, departure_time, seats_remaining
FROM flights
WHERE origin = 'LAX'
  AND seats_remaining > 0
  AND departure_time >= '2026-02-12'
  AND departure_time <  '2026-02-13';

What the schema gives the engine

  • origin is a three-character column, and it has an index
  • departure_time is a timestamp, so >= compares instants
  • seats_remaining is an integer, so > 0 is a numeric test

What the query reads

  • With an index on (origin, departure_time), a few pages, whatever the table size
  • Ten million rows in the table; a few hundred read

Without structure in the store, each application re-implements it

  • Parse and validate every record on read
  • Keep related records consistent with each other
  • Filter and search in its own code
  • Keep concurrent writers from corrupting a shared file

Each implementation can diverge

  • Application A reads departure_time as an ISO-8601 string
  • Application B parses the same field as a Unix timestamp
  • Both parse successfully until a timezone mismatch books the wrong flight

A database defines the structure once and enforces it for every client that connects.

Concurrent Access Without Coordination Corrupts Data

Several processes writing the same data at the same time is the normal condition of any shared store.

Two users book the last seat on flight 547 at the same moment, through different application servers:

  1. App server A reads the seat count: 1 remaining
  2. App server B reads the seat count: 1 remaining
  3. App server A writes 1 - 1 = 0 remaining and confirms the booking
  4. App server B writes 1 - 1 = 0 remaining and confirms the booking

Two confirmed bookings for one seat.

Lost update

  • The anomaly that occurs whenever a read-then-write pair is not atomic
  • Both servers read the same value, compute the same result, and the second write silently replaces the first
  • The same race as two threads incrementing one counter, now between two servers and one row

Not an application bug

  • The code is correct for a single writer
  • It fails because there are several writers and nothing coordinates them

A Database Locks One Row at a Time

A file can be locked (flock() on Unix, LockFileEx() on Windows) so that one writer proceeds at a time. The unit of locking decides what that costs.

File lock - one writer for the whole file

  • Lock the flights file
  • Read the seat count
  • Update it and write it back
  • Release the lock

Everything waits

  • While one process holds the lock, every other process waits, including those working on unrelated flights
  • 10,000 flights and 100 concurrent users: every operation on every flight queues behind one global lock

Row lock - one writer for one row

  • Only the row for flight 547 is locked
  • Writers to other flights proceed at the same time
  • The lock is held until the transaction commits, typically milliseconds, not for a rewrite of the file

Waiting happens only on the same row

  • Two writers wait on each other only when they update the same row
  • The size of the table adds no waiting; only writers to one row queue

Rebuilding row-level locking on top of files means re-implementing a large part of a database engine.

Declarative Queries Leave Strategy to the Engine

Procedural - the strategy is in the code

# Available flights from LAX on Feb 12
results = []
for flight in all_flights:               # every record
    if flight['origin'] == 'LAX' \
       and flight['seats_remaining'] > 0 \
       and flight['departure_time'][:10] == '2026-02-12':
        results.append(flight)
  • The loop always reads every record; 100x the data is 100x the time
  • A faster LAX lookup means a dictionary keyed by origin, and a rewrite of the loop
  • A date range means sorting by date, and another rewrite
  • Joining flights to bookings means nested loops and key matching, and more code

Every performance change is a code change, with its own test and deployment cycle.

Declarative - the strategy is the engine’s

SELECT flight_number, departure_time, seats_remaining
FROM flights
WHERE origin = 'LAX'
  AND seats_remaining > 0
  AND departure_time >= '2026-02-12'
  AND departure_time <  '2026-02-13';
  • The query names the result, not the path to it
  • The planner picks a scan or an index from what exists: the indexes present, the table size, how selective the condition is
  • An index added on origin switches the plan from ten million rows scanned to a few hundred read, with no application change

This is data independence: the application states the result, the engine chooses the access path.

Airline Booking Scenario

Airlines, airports, flights, aircraft, crews, passengers, and bookings.

Entities

  • Airports - code, name, city, timezone
  • Aircraft - registration, model, seat capacity, range
  • Flights - number, origin, destination, departure, arrival, aircraft assignment
  • Crew members - employee ID, name, certifications
  • Passengers - name, contact, frequent flyer status
  • Bookings - passenger, flight, seat, fare class, status

Relationships

  • A flight departs from one airport and arrives at another
  • A flight is operated by one aircraft
  • A flight has several crew members; a crew member works several flights
  • A booking connects one passenger to one flight
  • An aircraft has a maintenance history

What the schema must handle

  • Typed columns: timestamps, airport codes
  • Constraints: a flight must reference existing airports
  • Relationships: many-to-many crew assignments
  • Concurrency: seat booking races

The Relational Model

Every Row Has the Same Structure

A relational database stores data in tables: a fixed set of columns with declared names and types, and one row per record.

flight_id flight_number origin destination departure_time seats_remaining
1 AA 100 LAX JFK 2026-02-12 08:00 23
2 UA 512 SFO ORD 2026-02-12 09:30 0
3 DL 47 ATL LAX 2026-02-12 11:15 84

What is fixed

  • Every row has the same columns
  • Every column has one declared type
  • The store enforces the structure; a spreadsheet leaves it to convention

What is rejected

  • A row missing a NOT NULL column
  • A value of the wrong type for its column
  • Any write that would leave a row outside the declared structure

Types Restrict What Values a Column Can Hold

Column types are enforced constraints. The database rejects a write that violates them.

Common types

  • INTEGER - whole numbers (flight_id, seat count)
  • TEXT / VARCHAR(n) - character strings, optionally length-limited
  • CHAR(n) - fixed-length strings (airport codes: CHAR(3))
  • TIMESTAMP WITH TIME ZONE - an instant; plain TIMESTAMP carries no zone
  • BOOLEAN - true or false
  • NUMERIC(p, s) - exact decimal with declared precision (money, coordinates)
  • UUID - 128-bit universally unique identifier

Type choice has consequences

  • NUMERIC(10,2) for currency: floating-point arithmetic rounds, and a ledger cannot
  • TIMESTAMP WITH TIME ZONE for departures: 08:00 Pacific and 08:00 Eastern are different instants
  • CHAR(3) for airport codes: the fixed length enforces the IATA form

What the database rejects

-- Type mismatch: text in an integer column
INSERT INTO flights (seats_remaining) VALUES ('many');
-- ERROR: invalid input syntax for type integer

-- Too long for CHAR(3)
INSERT INTO flights (origin) VALUES ('Los Angeles');
-- ERROR: value too long for type character(3)

-- Out of range for INTEGER
INSERT INTO aircraft (seat_capacity) VALUES (9999999999);
-- ERROR: integer out of range
  • Checked at write time, not read time; invalid data never enters the table
  • Every reader can rely on seats_remaining being an integer and origin being three characters
  • A JSON field in a file accepts any value; validation is the reader’s job

Every Row Needs an Identity

A table with duplicate rows is ambiguous: which row does an update or a delete apply to? Without identity, operations on one record are undefined.

Primary keys provide identity

A primary key is a column, or a combination of columns, whose value identifies each row. The database enforces two rules:

  • Uniqueness - no two rows share a primary key value
  • NOT NULL - the primary key is never absent
CREATE TABLE airports (
    airport_code  CHAR(3) PRIMARY KEY,
    name          TEXT NOT NULL,
    city          TEXT NOT NULL,
    timezone      TEXT NOT NULL
);
  • airport_code identifies each airport
  • A second row with 'LAX' is rejected
  • A row with no airport code is rejected

Surrogate Keys Stay Stable When Facts Change

Natural key - data that already has meaning

  • Airport code (LAX, JFK): stable, universally understood
  • ISBN for books: assigned by an external authority
  • Carries meaning, but can change: airline mergers, code reassignment
  • A composite natural key (several columns) complicates every join that uses it

Surrogate key - generated by the database

  • Auto-incrementing integer, declared SERIAL* (*PostgreSQL syntax and behavior, here and wherever marked)
  • UUID (550e8400-e29b-41d4-a716-446655440000)
  • Stable and compact, but carries no meaning; flight_id = 7 says nothing about the flight

Common practice

  • A surrogate for internal identity
  • The natural key kept as a UNIQUE constraint, so the fact is still enforced
CREATE TABLE aircraft (
    aircraft_id   SERIAL PRIMARY KEY,
    registration  TEXT NOT NULL UNIQUE,
    model         TEXT NOT NULL,
    seat_capacity INTEGER NOT NULL
);
  • Every reference to an aircraft uses aircraft_id
  • A re-registered aircraft changes one column in one row; every reference to it is untouched

Foreign Keys Enforce Relationships Between Tables

Data about different entities lives in different tables. A flight has an origin airport and a destination airport. Rather than repeating the airport’s name, city, and timezone in every flight row, the flight table references the airports table.

CREATE TABLE flights (
    flight_id      SERIAL PRIMARY KEY,
    flight_number  TEXT NOT NULL,
    origin         CHAR(3) NOT NULL REFERENCES airports(airport_code),
    destination    CHAR(3) NOT NULL REFERENCES airports(airport_code),
    departure_time TIMESTAMP WITH TIME ZONE NOT NULL,
    aircraft_id    INTEGER REFERENCES aircraft(aircraft_id),
    seats_remaining INTEGER NOT NULL
);

What REFERENCES enforces

  • origin must hold a value that exists in airports.airport_code
  • A flight with origin = 'XYZ' is rejected if no airport XYZ exists
  • This is referential integrity: every reference points to a real row
INSERT INTO flights (flight_number, origin, destination, ...)
VALUES ('AA 100', 'ZZZ', 'LAX', ...);
-- ERROR: insert or update on table "flights"
-- violates foreign key constraint
-- Key (origin)=(ZZZ) is not present
-- in table "airports"

When the referenced row is deleted - shown for the nullable aircraft_id

  • RESTRICT (default) unless the child has no meaning without the parent
  • CASCADE when the child is part of the parent (an itinerary’s legs)
  • SET NULL only where the reference was optional to begin with

References Eliminate Redundant Data

Without a reference: the airport repeated in every flight row

flight origin_code origin_name origin_city origin_tz
AA 100 LAX Los Angeles International Los Angeles America/Los_Angeles
UA 200 LAX Los Angeles International Los Angeles America/Los_Angeles
DL 300 LAX Los Angeles International Los Angeles America/Los_Angeles
  • 10,000 flights from LAX store the name "Los Angeles International" 10,000 times
  • A change to the name is 10,000 updates; miss one and the table disagrees with itself
  • Nothing in the store says the four origin_* columns must agree from row to row

With a reference: the airport stored once

airports

airport_code name city timezone
LAX Los Angeles International Los Angeles America/Los_Angeles

flights

flight_number origin destination
AA 100 LAX JFK
UA 200 LAX ORD
DL 300 LAX ATL
  • The name is stored once; flights carry the three-character code
  • A change to the name is one update to one row, and every flight reflects it
  • The foreign key guarantees the code in every flight row names a real airport

Constraints Express Business Rules in the Schema

Beyond keys and types, the database can enforce arbitrary conditions on data.

NOT NULL - a value must be provided

flight_number TEXT NOT NULL

A flight without a flight number cannot exist. The insert is rejected rather than an incomplete record stored.

UNIQUE - no duplicates

aircraft_registration TEXT UNIQUE

No two aircraft share a registration number. Unlike a primary key, a UNIQUE column can be NULL (an aircraft awaiting registration).

DEFAULT - the value when none is provided

booking_status TEXT DEFAULT 'confirmed'

A new booking without an explicit status is 'confirmed'.

CHECK - an arbitrary condition

seats_remaining INTEGER CHECK (seats_remaining >= 0)

The seat count cannot go negative. An update that would set it to -1 is rejected, whatever the application logic that produced it.

CHECK (departure_time < arrival_time)

A flight cannot arrive before it departs. This catches data-entry errors, application bugs, and timezone conversion mistakes before they become rows.

Constraints Are Centralized Enforcement

Without a constraint in the schema, every application validates on its own.

Every writer validates on its own

  • The web application checks seats_remaining >= 0 before confirming a booking
  • The batch import tool checks it before loading flight data
  • The admin console checks it before a manual adjustment
  • A mobile API is added six months later; its developer does not know the rule, and negative seat counts enter the table

One writer skips the check

  • The data is now corrupt
  • The source of the corruption is hard to trace, because every writer’s code looks correct on its own

One CHECK constraint on the column

  • The fourth application’s bad write is rejected by the database
  • The rule is stated once, in the schema, and holds for every client, present and future

NULL Represents Missing or Unknown Data

NULL is not zero, not an empty string, and not false. NULL means the value is not known or does not apply.

Where NULL belongs

CREATE TABLE flights (
    ...
    actual_departure TIMESTAMP,  -- NULL until takeoff
    gate_number      TEXT,       -- NULL if not assigned
    delay_minutes    INTEGER     -- NULL if on time
);
  • A flight that has not departed has no actual_departure; the value is unknown, not zero and not a placeholder date
  • NULL is the correct representation of an unknown

Where NULL is prohibited

flight_number TEXT NOT NULL     -- every flight has a number
origin        CHAR(3) NOT NULL  -- every flight has an origin
  • NOT NULL is declared where absence would make the row meaningless
  • A flight without an origin is not a flight

NULL in expressions

Any arithmetic or comparison with NULL yields NULL:

NULL = NULL       -- NULL  (not TRUE)
NULL != NULL      -- NULL  (not TRUE)
NULL + 5          -- NULL
NULL > 0          -- NULL
  • This is three-valued logic: TRUE, FALSE, NULL
  • Most languages have two truth values; SQL has three
-- Matches no rows, even where gate_number is NULL
SELECT * FROM flights WHERE gate_number = NULL;

-- Correct
SELECT * FROM flights WHERE gate_number IS NULL;

The first query compares each row’s gate_number with NULL. The comparison yields NULL, which is not TRUE, so no row matches.

NULL Propagation Affects Aggregation and Logic

Aggregation with NULLs

-- delay_minutes: [10, NULL, 30, NULL, 20]

SELECT COUNT(*)             FROM flights;  -- 5
SELECT COUNT(delay_minutes) FROM flights;  -- 3
SELECT AVG(delay_minutes)   FROM flights;  -- 20.0
  • COUNT(*) counts rows: 5 rows exist
  • COUNT(delay_minutes) counts non-NULL values: 3
  • AVG(delay_minutes) averages non-NULL values: (10 + 30 + 20) / 3 = 20.0, not (10 + 0 + 30 + 0 + 20) / 5 = 12

COUNT(*) and COUNT(column) return different numbers on the same data. Treating NULL as zero in an average silently changes the result.

Logical operations

  • FALSE AND NULL is FALSE: whatever the unknown value, the conjunction is false
  • TRUE OR NULL is TRUE for the same reason
  • TRUE AND NULL is NULL: the result depends on the unknown value

CREATE TABLE Declares Types, Keys, and Rules Together

One statement carries the column types, the primary key, the foreign keys, and the business rules. Everything the store enforces about a booking is declared here:

CREATE TABLE bookings (
    booking_id    SERIAL PRIMARY KEY,
    passenger_id  INTEGER NOT NULL REFERENCES passengers(passenger_id),
    flight_id     INTEGER NOT NULL REFERENCES flights(flight_id),
    seat_number   TEXT,
    fare_class    CHAR(1) NOT NULL CHECK (fare_class IN ('F', 'J', 'W', 'Y')),
    booking_time  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    status        TEXT NOT NULL DEFAULT 'confirmed'
                  CHECK (status IN ('confirmed', 'cancelled', 'checked_in', 'boarded')),
    UNIQUE (flight_id, seat_number)
);

Identity and references

  • booking_id - surrogate key, generated
  • passenger_id - must reference an existing passenger
  • flight_id - must reference an existing flight
  • Neither reference can be NULL; a booking without a passenger or a flight is meaningless

Constraints as business rules

  • fare_class is one of F (first), J (business), W (premium economy), Y (economy)
  • status follows a declared lifecycle; no other strings
  • (flight_id, seat_number) is UNIQUE: two passengers cannot hold one seat on one flight
  • seat_number can be NULL; a seat is assigned later
  • booking_time defaults to the time of the insert

The Airline Schema

Airports, aircraft, flights, passengers, and bookings, connected by foreign keys.

References

Column References
flights.origin airports.airport_code
flights.destination airports.airport_code
flights.aircraft_id aircraft.aircraft_id
bookings.flight_id flights.flight_id
bookings.passenger_id passengers.passenger_id

What the schema enforces

  • A flight references only existing airports and aircraft
  • A booking references only an existing flight and passenger
  • Seat counts never go negative
  • Fare classes are one of F, J, W, Y
  • No two bookings hold one seat on one flight

SQL: Structured Query Language

SQL Is the Language of Relational Databases

Every relational database is driven by the same language: SQL (Structured Query Language), standardized since 1986. One language defines the tables, writes the rows, and asks the questions.

Three kinds of statement

  • Define structure - CREATE TABLE, ALTER TABLE, CREATE INDEX: the schema
  • Change rows - INSERT, UPDATE, DELETE
  • Ask questions - SELECT, with joins, grouping, and ordering

The engines that speak it

  • PostgreSQL, MySQL and MariaDB, SQLite, SQL Server, Oracle
  • A managed service such as RDS runs one of these; the language is the same
  • The engine is chosen separately from the schema and the queries, and can be replaced without rewriting either

Dialects

  • The core (tables, types, keys, SELECT, joins, aggregates, transactions) is shared and is what this lecture teaches
  • The edges differ: generated keys (SERIAL), casts (::date), some types and functions
  • This course uses PostgreSQL; syntax or behavior that is PostgreSQL’s own is marked *

Why one language matters

  • A query written for one engine runs, at most lightly edited, on another
  • Libraries, tools, and people transfer between engines
  • The skill is the model and the language, not a product

A Query Is Text the Engine Executes

The application never opens the database’s files. It sends SQL as text over a connection; the engine parses it, plans how to run it, runs it, and returns rows.

import psycopg2
conn = psycopg2.connect(host='db.example.internal',
                        dbname='airline', user='app')
cur = conn.cursor()
cur.execute("""
    SELECT flight_number, seats_remaining
    FROM flights
    WHERE origin = %s AND seats_remaining > 0
""", ('LAX',))
rows = cur.fetchall()   # list of tuples
  • The query is a string; the engine, not Python, interprets it
  • The parameter %s is sent separately from the text, so a value is never spliced into the query
  • The result arrives as rows over the same connection

  • Parse, plan, execute is the same sequence for every statement, from CREATE TABLE to a six-table join
  • The plan step is where the engine decides between a scan and an index

A Query Names Columns, a Table, and a Condition

Most reads have this shape. The query says what to return; how to find it is the engine’s decision.

SELECT flight_number, origin, destination, seats_remaining
FROM flights
WHERE seats_remaining > 0
  AND departure_time BETWEEN '2026-02-12' AND '2026-02-13';

SELECT - which columns to return

SELECT *                     -- all columns
SELECT flight_number         -- one column
SELECT flight_number, origin -- several columns
SELECT DISTINCT origin       -- unique values only

FROM - which table, or tables, to read

FROM flights
FROM flights f               -- alias, used in joins

WHERE - which rows to keep

WHERE origin = 'LAX'
WHERE seats_remaining > 0
WHERE origin = 'LAX' AND destination = 'JFK'
WHERE origin IN ('LAX', 'SFO', 'SEA')
WHERE departure_time BETWEEN '2026-02-12' AND '2026-02-13'
WHERE flight_number LIKE 'AA%'         -- starts with AA
WHERE gate_number IS NULL              -- no gate assigned
WHERE gate_number IS NOT NULL          -- gate assigned
  • Each condition is a predicate; predicates combine with AND, OR, and NOT
  • The expression is evaluated against every row; the row is kept when it is TRUE, and dropped when it is FALSE or NULL

SQL Execution Order Differs from Written Order

A query is written SELECT ... FROM ... WHERE ... GROUP BY ... ORDER BY. The engine evaluates the clauses in a different order.

What follows from the order

  • A column alias defined in SELECT cannot be used in WHERE; SELECT has not run yet
  • HAVING can filter on an aggregate and WHERE cannot; WHERE runs before GROUP BY
  • ORDER BY can use a SELECT alias; it runs after SELECT

ORDER BY and LIMIT Control the Result

SELECT flight_number, departure_time, seats_remaining
FROM flights
WHERE origin = 'LAX'
  AND seats_remaining > 0
ORDER BY departure_time ASC
LIMIT 10;

ORDER BY - sort the result

ORDER BY departure_time              -- ascending (default)
ORDER BY departure_time ASC          -- explicit ascending
ORDER BY departure_time DESC         -- descending
ORDER BY origin, departure_time      -- by origin first,
                                     -- then by time within origin
  • Without ORDER BY, rows come back in whatever order was cheapest to produce
  • That order is not guaranteed and can change between two runs of the same query

LIMIT - restrict the count

LIMIT 10                 -- first 10 rows
LIMIT 10 OFFSET 20       -- rows 21-30
  • LIMIT without ORDER BY returns an arbitrary subset; which 10 rows is undefined

Both together

“The next 5 flights departing LAX” needs a sort and a limit:

SELECT flight_number, departure_time
FROM flights
WHERE origin = 'LAX'
  AND departure_time > now()
ORDER BY departure_time ASC
LIMIT 5;

INSERT Adds Rows That Pass Every Constraint

INSERT INTO airports (airport_code, name, city, timezone)
VALUES ('LAX', 'Los Angeles International', 'Los Angeles', 'America/Los_Angeles');

The column list is explicit

  • It names which columns receive values, in which order
  • Columns with a DEFAULT, or that allow NULL, can be left out
-- booking_id is SERIAL (generated)
-- booking_time has DEFAULT now()
INSERT INTO bookings (passenger_id, flight_id, fare_class)
VALUES (42, 7, 'Y');
  • booking_id - generated
  • booking_time - filled by the DEFAULT
  • seat_number - NULL; no value was given

Several rows in one statement

INSERT INTO airports (airport_code, name, city, timezone)
VALUES
    ('JFK', 'John F. Kennedy', 'New York', 'America/New_York'),
    ('ORD', 'O''Hare', 'Chicago', 'America/Chicago'),
    ('ATL', 'Hartsfield-Jackson', 'Atlanta', 'America/New_York');

Every constraint is checked before the row enters

-- Foreign key violation
INSERT INTO flights (flight_number, origin, destination, ...)
VALUES ('AA 100', 'ZZZ', 'LAX', ...);
-- ERROR: Key (origin)=(ZZZ) is not present in "airports"

-- Unique violation
INSERT INTO airports (airport_code, ...) VALUES ('LAX', ...);
-- ERROR: duplicate key value violates unique constraint

UPDATE and DELETE Change the Rows That Match

UPDATE - change columns in the rows that match

UPDATE flights
SET seats_remaining = seats_remaining - 1
WHERE flight_id = 7;
UPDATE bookings
SET status = 'cancelled'
WHERE booking_id = 1234;
-- Move every flight off a decommissioned aircraft
UPDATE flights
SET aircraft_id = 501
WHERE aircraft_id = 299;
  • Constraints apply to the new values; setting seats_remaining to -1 is rejected by the CHECK

DELETE - remove the rows that match

DELETE FROM bookings
WHERE status = 'cancelled'
  AND booking_time < '2025-01-01';
  • Foreign keys decide what can go: deleting an airport that flights reference is blocked under RESTRICT

Without WHERE, every row

UPDATE flights SET seats_remaining = 0;    -- every flight
DELETE FROM bookings;                       -- every booking
  • Both statements run against the whole table
  • There is no confirmation and no undo outside a transaction; the WHERE clause is the safeguard

Data Lives in Separate Tables

Each table holds one kind of entity. A relationship is a foreign key: an ID or a code that names a row in another table.

One booking row:

booking_id: 1001
passenger_id: 42
flight_id: 7
seat_number: 14A
fare_class: Y
status: confirmed

Present - the booking exists; which passenger, which flight, which seat

Absent - the passenger’s name, the flight number, origin, destination, departure time; each in another table

“Passenger 42’s itinerary with flight details and airport names”

  • Needs bookings, flights, and airports
  • Airports twice: once for the origin, once for the destination

JOIN Matches Rows Across Tables

A JOIN combines rows from two tables on a matching condition. For each row of the first table, the engine finds the rows of the second where the condition holds and produces one combined row per match.

SELECT b.booking_id, b.seat_number, f.flight_number, f.departure_time
FROM bookings b
JOIN flights f ON b.flight_id = f.flight_id
WHERE b.passenger_id = 42;
  • FROM bookings b - start from the booking rows
  • JOIN flights f - for each booking, find the flight
  • ON b.flight_id = f.flight_id - the match condition: foreign key equals primary key
  • Result: one row per booking, with the flight’s columns attached

  • Bookings 1001 and 1003 both match flight 7: two passengers on one flight, two result rows
  • Flight 23 has no booking from passenger 42 and is absent from the result
  • One-to-many: one flight, many bookings; the join produces one output row per booking, not per flight

ON Says Which Rows Belong Together

JOIN flights f ON b.flight_id = f.flight_id

The ON clause says how rows of the two tables relate: usually a foreign key in one table equal to the primary key of the other.

Without a condition: the cross product

SELECT b.booking_id, f.flight_number
FROM bookings b, flights f;
-- equivalently: CROSS JOIN
  • Every booking paired with every flight
  • 1,000 bookings × 10,000 flights = 10 million rows, almost all meaningless

ON keeps the pairs where the keys match

ON as a filter on the pairs

bookings.flight_id flights.flight_id kept?
7 7 yes
7 15 no
7 23 no
15 7 no
15 15 yes
15 23 no
  • 6 possible pairs, 2 kept
  • The engine never builds the full cross product; it uses indexes and hash tables to reach the matching pairs directly

INNER JOIN Returns Only Matching Rows

The default JOIN, also written INNER JOIN, includes a row in the result only if a match exists in both tables.

SELECT f.flight_number, a.model AS aircraft
FROM flights f
JOIN aircraft a ON f.aircraft_id = a.aircraft_id;
flight_number | aircraft
--------------+-----------------
AA 100        | Boeing 737-800
UA 512        | Airbus A320
DL 47         | Boeing 767-300
  • 500 flights, 20 with no aircraft assigned (aircraft_id is NULL)
  • The result has 480 rows; the 20 unassigned flights have no match in aircraft and are dropped

LEFT JOIN Keeps Rows With No Match

LEFT JOIN returns every row of the left table. Where a match exists on the right, its columns are filled; where none exists, they are NULL.

SELECT f.flight_number, a.model AS aircraft
FROM flights f
LEFT JOIN aircraft a ON f.aircraft_id = a.aircraft_id;
flight_number | aircraft
--------------+-----------------
AA 100        | Boeing 737-800
UA 512        | Airbus A320
DL 47         | Boeing 767-300
SW 220        | NULL
  • All 500 flights appear, including the 20 with no aircraft
  • The aircraft column is NULL for those 20 rows

Joins Chain Across Several Tables

Each JOIN adds one table on its own condition. A chain of joins follows the foreign keys through the schema.

SELECT b.booking_id, b.fare_class, b.seat_number,
       f.flight_number, f.departure_time,
       a_orig.name AS origin_airport,
       a_dest.name AS destination_airport
FROM bookings b
JOIN flights f        ON b.flight_id = f.flight_id
JOIN airports a_orig  ON f.origin = a_orig.airport_code
JOIN airports a_dest  ON f.destination = a_dest.airport_code
WHERE b.passenger_id = 42;

The chain

  1. bookings - passenger 42’s bookings
  2. flights - each booking’s flight, by flight_id
  3. airports as a_orig - the origin code resolved to a name
  4. airports as a_dest - the destination code resolved to a name
  • The airports table appears twice, under two aliases, because a flight references two airports
  • The aliases say which role each join fills
booking | fare | seat | flight | departure        | origin           | dest
--------+------+------+--------+------------------+------------------+-----------
1001    | Y    | 14A  | AA 100 | 2026-02-12 08:00 | Los Angeles Intl | John F. Kennedy
1002    | Y    | 22C  | DL 47  | 2026-02-12 11:15 | Hartsfield-Jksn  | Los Angeles Intl

Each result row is assembled from four tables

  • booking_id, fare, seat - from bookings
  • flight, departure - from flights
  • origin, dest - from airports, two separate joins

Joins Put the Pieces Back Together

  • The airport’s name is in one row and the flight’s details in one row; the join puts them back together at query time
  • Reassembly costs CPU and I/O to match rows across tables
  • Indexes on the join columns, the foreign keys and primary keys, keep that cost proportional to the size of the result, not of the tables

GROUP BY Aggregates Rows into Groups

SELECT origin, COUNT(*) AS flight_count, AVG(seats_remaining) AS avg_seats
FROM flights
WHERE departure_time BETWEEN '2026-02-12' AND '2026-02-13'
GROUP BY origin
ORDER BY flight_count DESC;
origin | flight_count | avg_seats
-------+--------------+----------
LAX    |           47 |     34.2
ORD    |           38 |     52.1
ATL    |           35 |     28.7
JFK    |           31 |     41.5

Aggregate functions operate on sets of rows

  • COUNT(*) - number of rows
  • COUNT(column) - number of non-NULL values
  • SUM(column) - total
  • AVG(column) - mean of the non-NULL values
  • MIN(column) / MAX(column) - extremes

Without GROUP BY, an aggregate covers the whole result; with GROUP BY, one value per group.

WHERE filters rows, HAVING filters groups

SELECT origin, COUNT(*) AS flight_count
FROM flights
WHERE departure_time > '2026-02-12'  -- rows, before grouping
GROUP BY origin
HAVING COUNT(*) > 30;                -- groups, after aggregation
  • “Airports with more than 30 departures on the day”: WHERE keeps the day’s flights, GROUP BY partitions them by origin, HAVING keeps the groups over 30

Aggregation Loses the Individual Rows

  • 7 rows become 3 summary rows; the individual rows are gone from the result
  • GROUP BY answers “how many” and “what average”, not “which flight”
  • A question about individual rows in the context of their group needs something else

Aggregating over a Join Counts the Joined Rows

Bookings per airline on one day

SELECT
    substring(f.flight_number, 1, 2) AS airline,
    COUNT(b.booking_id) AS total_bookings,
    COUNT(DISTINCT f.flight_id) AS flights
FROM flights f
JOIN bookings b ON f.flight_id = b.flight_id
WHERE f.departure_time::date = '2026-02-12'  -- ::date casts to a date*
GROUP BY airline
ORDER BY total_bookings DESC;
airline | total_bookings | flights
--------+----------------+--------
AA      |           1247 |     23
UA      |            983 |     18
DL      |            871 |     21

Execution order

  1. FROM + JOIN - one row per booking-flight pair
  2. WHERE - keep the day’s flights
  3. GROUP BY - partition by airline prefix
  4. SELECT aggregates - count bookings and distinct flights per group
  5. ORDER BY - most bookings first

Two counts on the same rows

  • COUNT(b.booking_id) counts every pair in the group: one per booking
  • COUNT(DISTINCT f.flight_id) counts each flight once, however many bookings it has
  • 1,247 bookings over 23 flights: about 54 passengers per flight

Queries Can Be Built from Other Queries

A question that depends on the answer to another question is written as a query over a query.

Subquery - nested inside the outer query

-- Flights with above-average bookings
SELECT f.flight_number, COUNT(b.booking_id) AS bookings
FROM flights f
JOIN bookings b ON f.flight_id = b.flight_id
GROUP BY f.flight_id, f.flight_number
HAVING COUNT(b.booking_id) > (
    SELECT AVG(booking_count) FROM (
        SELECT COUNT(*) AS booking_count
        FROM bookings GROUP BY flight_id
    ) sub
);

Reads inside-out:

  1. Innermost - bookings per flight
  2. Middle - the average of those counts
  3. Outer - the flights above that average

CTE (common table expression) - the same logic, read top to bottom

WITH per_flight AS (
    SELECT flight_id, COUNT(*) AS booking_count
    FROM bookings
    GROUP BY flight_id
),
avg_bookings AS (
    SELECT AVG(booking_count) AS avg_count
    FROM per_flight
)
SELECT f.flight_number, pf.booking_count
FROM per_flight pf
JOIN flights f ON pf.flight_id = f.flight_id
WHERE pf.booking_count > (SELECT avg_count FROM avg_bookings);
  • Each WITH clause defines a named intermediate result
  • A CTE can use the CTEs before it
  • The main query uses them like tables

Window Functions Keep Every Row

GROUP BY collapses a group to one row. A window function computes over the rows of a group while every row stays in the result.

SELECT flight_number, origin, seats_remaining,
       RANK() OVER (
           PARTITION BY origin
           ORDER BY seats_remaining DESC
       ) AS availability_rank
FROM flights
WHERE departure_time::date = '2026-02-12';
flight_number | origin | seats | rank
--------------+--------+-------+-----
AA 100        | LAX    |    84 |    1
UA 205        | LAX    |    47 |    2
DL 300        | LAX    |    23 |    3
UA 512        | ORD    |    91 |    1
AA 340        | ORD    |    55 |    2
  • Every row is present; the rank is computed within each origin partition
  • PARTITION BY is the grouping; ORDER BY inside OVER is the order the function sees

Other window functions

  • ROW_NUMBER() / RANK() / DENSE_RANK() - numbering and ranking
  • SUM(…) OVER (…) - running totals
  • LAG(col, n) / LEAD(col, n) - the value n rows before or after

Schema Design and ER Modeling

Schema Design Decides What the Tables Are

SQL operates on tables that already exist. Someone decided which tables, chose the columns and their types, and drew the foreign keys. The airline schema so far has no crew table, and a crew member working a flight has nowhere to be recorded.

Schema design is the step before the queries. Given a domain, decide:

  • Which things need a table of their own
  • Which attributes each has
  • How the things relate to each other
  • Which constraints make invalid data impossible

Entity-Relationship (ER) modeling is the design tool:

  • A visual notation for entities, attributes, and relationships
  • Independent of any engine; a design artifact, not SQL
  • Forces the questions before DDL is written: can a flight have two aircraft? must every aircraft be assigned to a flight?
  • The diagram is the plan; the CREATE TABLE statements are its implementation

ER Diagrams Show Entities, Attributes, and Relationships

Entities - things with an existence of their own that the system tracks

  • Airport, Aircraft, Flight, Crew Member, Passenger
  • Each becomes a table with a primary key

Attributes - properties of an entity

  • Airport: code, name, city, timezone
  • Aircraft: registration, model, seat capacity, range
  • Each becomes a column with a type

Relationships - associations between entities

  • A flight departs from an airport
  • A flight is operated by an aircraft
  • A passenger books a flight
  • Relationships are named; the verb carries the direction and the meaning

Cardinality Says How Many on Each Side

  • One-to-one - one aircraft, one registration certificate; rare, and often a sign the two belong in one table
  • One-to-many - one airport, many departing flights; the most common relationship
  • Many-to-many - a crew member works many flights and a flight carries many crew; neither side can hold a single reference to the other

Foreign Key for One-to-Many, Junction Table for Many-to-Many

One-to-many: foreign key in the “many” table

An airport has many flights, so the reference lives in flights:

CREATE TABLE flights (
    ...
    origin CHAR(3) REFERENCES airports(airport_code)
);
  • The airport row has no reference back; the relationship is read from the many side

One-to-one: foreign key plus UNIQUE

CREATE TABLE registration_certs (
    cert_id     SERIAL PRIMARY KEY,
    aircraft_id INTEGER UNIQUE
                REFERENCES aircraft(aircraft_id),
    ...
);
  • UNIQUE on aircraft_id means no two certificates name the same aircraft

Many-to-many: a junction table

Neither table can hold one foreign key to the other. A junction table holds one row per pair:

CREATE TABLE crew_assignments (
    crew_id   INTEGER REFERENCES crew(crew_id),
    flight_id INTEGER REFERENCES flights(flight_id),
    role      TEXT NOT NULL,
    PRIMARY KEY (crew_id, flight_id)
);
crew_id | flight_id | role
--------+-----------+---------
101     | 7         | captain
102     | 7         | first_officer
101     | 15        | captain
103     | 15        | first_officer
  • One row per crew-flight pair
  • The composite primary key (crew_id, flight_id) prevents a duplicate assignment
  • Readable in both directions: who is on flight 7, and which flights crew 101 works

Participation Decides Whether the Foreign Key Can Be NULL

Total participation - every instance takes part in the relationship

  • Every flight has an origin airport; a flight without one makes no sense
  • Implementation: NOT NULL on the foreign key
origin CHAR(3) NOT NULL REFERENCES airports(airport_code)

Partial participation - taking part is optional

  • An aircraft may not be assigned to any flight yet: new, or in maintenance
  • Implementation: the foreign key allows NULL
aircraft_id INTEGER REFERENCES aircraft(aircraft_id)
-- NULL means "not currently assigned"

The diagram records the business rule; the DDL enforces it.

A Weak Entity Is Identified by Its Parent

Most entities are identified by their own attributes: an airport by its code, an aircraft by its registration. A weak entity has an identity only inside its parent.

A flight leg, one segment of a multi-stop flight:

  • Flight AA 100, leg 1: LAX to DEN
  • Flight AA 100, leg 2: DEN to JFK

“Leg 1” alone identifies nothing; every multi-stop flight has a leg 1. The leg is leg 1 of flight AA 100.

CREATE TABLE flight_legs (
    flight_id      INTEGER REFERENCES flights(flight_id)
                   ON DELETE CASCADE,
    leg_number     INTEGER,
    origin         CHAR(3) REFERENCES airports(airport_code),
    destination    CHAR(3) REFERENCES airports(airport_code),
    departure_time TIMESTAMP WITH TIME ZONE,
    arrival_time   TIMESTAMP WITH TIME ZONE,
    PRIMARY KEY (flight_id, leg_number)
);

Composite primary key (flight_id, leg_number)

  • flight_id - which flight, from the parent
  • leg_number - which leg within that flight
  • Together unique; neither alone is

What makes it weak

  • It cannot exist without the parent; deleting the flight deletes its legs (CASCADE)
  • Its primary key includes the parent’s key
  • The relationship to the parent is part of its identity, not only an association

Other examples: line items on an invoice, rooms in a building, episodes in a season.

Subtypes Go in One Table or in Several

Some entities are subtypes of a more general one. Crew members are all employees with a name, an employee ID, and a hire date; pilots also have type ratings, and cabin crew have language certifications.

One table with a type column

CREATE TABLE crew (
    crew_id      SERIAL PRIMARY KEY,
    name         TEXT NOT NULL,
    hire_date    DATE NOT NULL,
    crew_type    TEXT NOT NULL CHECK (crew_type IN ('pilot', 'cabin')),
    type_ratings TEXT[],       -- array column*; NULL for cabin crew
    flight_hours INTEGER,      -- NULL for cabin crew
    languages    TEXT[],       -- NULL for pilots
    service_cert TEXT          -- NULL for pilots
);
  • One table, no joins; every subtype’s columns are NULL in the other subtype’s rows
  • Inapplicable columns accumulate as the subtypes diverge

Separate tables sharing the primary key

CREATE TABLE crew (
    crew_id SERIAL PRIMARY KEY,
    name TEXT NOT NULL, hire_date DATE NOT NULL
);
CREATE TABLE pilots (
    crew_id INTEGER PRIMARY KEY REFERENCES crew(crew_id),
    type_ratings TEXT[], flight_hours INTEGER
);
CREATE TABLE cabin_crew (
    crew_id INTEGER PRIMARY KEY REFERENCES crew(crew_id),
    languages TEXT[], service_cert TEXT
);
  • Each subtype holds only its own columns; shared plus specific attributes need a join
  • The foreign key guarantees every pilot is a crew member

Requirements Become Entities and Relationships

Requirements for the booking system

  • Airlines operate flights between airports
  • Each flight is operated by one aircraft
  • Flights have scheduled departure and arrival times
  • Crew members are assigned to flights; a flight has several crew
  • Passengers book flights
  • A booking records the passenger, flight, seat, and fare class
  • Aircraft have maintenance records

Entities - the nouns that recur: Airport, Aircraft, Flight, Crew Member, Passenger, Booking, Maintenance Record

Relationships - the verbs between them, each with its cardinality and participation

  • Flight departs from / arrives at Airport: many-to-one, total
  • Flight operated by Aircraft: many-to-one, partial (an aircraft may be unassigned)
  • Crew Member assigned to Flight: many-to-many
  • Passenger makes Booking: one-to-many
  • Booking for Flight: many-to-one, total
  • Aircraft has Maintenance Record: one-to-many

Every relationship line in the diagram comes from one of these sentences.

The Airline ER Diagram

Cardinalities

  • Airport 1 - N Flight · Aircraft 1 - N Flight · Aircraft 1 - N Maintenance
  • Crew M - N Flight
  • Passenger 1 - N Booking · Flight 1 - N Booking

What the diagram already decides

  • Every one-to-many puts the foreign key in the “many” table
  • Crew-Flight needs the junction table crew_assignments
  • Booking is a junction between Passenger and Flight that carries its own attributes: seat, fare class, status
  • Maintenance is a weak entity, identified by aircraft plus date or sequence

The ER Diagram Maps to Tables by Fixed Rules

Mapping rules

  • Entity - a table
  • Attribute - a column with a type
  • Primary key - PRIMARY KEY
  • 1:N relationship - foreign key in the “many” table
  • M:N relationship - junction table with a composite key
  • Total participation - NOT NULL on the foreign key
  • Partial participation - nullable foreign key
  • Weak entity - composite primary key that includes the parent’s key

The rules applied to the airline schema

-- Entity: Airport
CREATE TABLE airports (
    airport_code CHAR(3) PRIMARY KEY, ...
);

-- Entity: Flight (1:N with Airport, total;
--                 1:N with Aircraft, partial)
CREATE TABLE flights (
    flight_id SERIAL PRIMARY KEY,
    origin CHAR(3) NOT NULL
        REFERENCES airports(airport_code),
    aircraft_id INTEGER          -- nullable
        REFERENCES aircraft(aircraft_id),
    ...
);

-- M:N: Crew <-> Flight
CREATE TABLE crew_assignments (
    crew_id INTEGER REFERENCES crew(crew_id),
    flight_id INTEGER REFERENCES flights(flight_id),
    role TEXT NOT NULL,
    PRIMARY KEY (crew_id, flight_id)
);

Design Decisions Have Downstream Consequences

The diagram forces decisions that every query, every constraint, and every later schema change depends on.

Can a booking exist without a flight?

  • Yes: flight_id nullable, LEFT JOINs everywhere, orphaned bookings handled in application code
  • No: NOT NULL REFERENCES, and the database refuses an orphan at write time

Can two passengers share a seat?

  • No: UNIQUE(flight_id, seat_number), enforced whatever the application does
  • Yes (standby): no unique constraint, and the application manages the conflict

Is fare class free text or an enumeration?

  • Free text: flexible, unvalidated, and inconsistent within months ("economy", "Economy", "Y", "econ")
  • CHECK constraint fare_class IN ('F','J','W','Y'): consistent, and a new class is a schema change

These decisions are expensive to reverse

  • Adding NOT NULL to a column with existing NULLs is a data migration
  • Splitting a table rewrites every query that referenced it
  • Changing a primary key cascades into every foreign key that points at it

The cost grows with the code that depends on the schema

  • Queries, application logic, indexes, and migrations all encode assumptions about the tables
  • The diagram is where the decisions are made explicitly, before code depends on them

Normalization

Redundant Data Eventually Causes Anomalies

A single table tracking crew assignments together with flight and airport details:

crew_id crew_name flight_id flight_number origin origin_name
101 Chen 7 AA 100 LAX Los Angeles Intl
102 Okafor 7 AA 100 LAX Los Angeles Intl
101 Chen 15 DL 47 ATL Hartsfield-Jackson
103 Park 15 DL 47 ATL Hartsfield-Jackson

The same fact in many rows

  • "Los Angeles Intl" appears once per crew member per flight from LAX
  • So does the flight number, and so does each crew member’s name
  • The waste is storage; the damage is what the repetition allows

Three anomalies

  • Update anomaly - LAX changes its name; every row holding LAX must change, and a missed row leaves the table disagreeing with itself
  • Insert anomaly - a new airport cannot be recorded until some crew member is assigned to some flight from it
  • Delete anomaly - deleting the last crew assignment out of ATL also deletes the fact that ATL is named “Hartsfield-Jackson”

What the Three Anomalies Look Like

Update anomaly: a partial update

UPDATE crew_flights
SET origin_name = 'Los Angeles International'
WHERE origin = 'LAX';
-- 1 of 2 rows updated before a timeout
  • Two rows, two names for one airport, and no constraint that says they must agree

Insert anomaly: an unrelated fact cannot enter

-- Record Denver International (DEN)
INSERT INTO crew_flights (origin, origin_name)
VALUES ('DEN', 'Denver International');
-- ERROR: crew_id, flight_id cannot be NULL
  • An airport exists only as part of a crew assignment

Delete anomaly: a fact leaves with an unrelated row

DELETE FROM crew_flights WHERE flight_id = 15;
  • Flight 15 was the only flight out of ATL; its name went with the assignments

A Functional Dependency Means One Value Determines Another

A functional dependency X → Y: if two rows agree on X, they must agree on Y. It is the formal way to say “Y is an attribute of X”.

In the airline schema

  • airport_code → name, city, timezone: two rows with LAX must show the same name
  • flight_id → flight_number, origin, destination, departure_time
  • crew_id → crew_name, hire_date

Dependencies inside the wide table

crew_id crew_name flight_id flight_number origin origin_name
  • crew_id → crew_name: a crew attribute
  • flight_id → flight_number, origin: flight attributes
  • origin → origin_name: an airport attribute

Transitive dependency

  • flight_id → origin → origin_name
  • The airport’s name depends on the airport code, not on the flight; stored per flight, it repeats once per flight from that airport

1NF: Every Cell Holds One Value

A normal form is a rule a table meets; each successive form removes one kind of dependency problem. First normal form (1NF): each column holds a single atomic value, and each row is identifiable.

Violation: a list in a cell

crew_id name certifications
101 Chen 737, 767, A320
102 Okafor 737
  • certifications is a comma-separated list
  • “Which crew are certified on the 767?” becomes string parsing
-- Finds "767" inside "737, 767, A320"
-- and also inside "7672" or "B767"
WHERE certifications LIKE '%767%'

1NF: one value per cell, in rows or in a table

Separate rows:

crew_id name certification
101 Chen 737
101 Chen 767
101 Chen A320
102 Okafor 737

A separate table, which also stops repeating the name:

crew_id aircraft_type
101 737
101 767
101 A320
102 737
WHERE aircraft_type = '767'

2NF: Every Column Depends on the Whole Key

Second normal form (2NF): in 1NF, and every non-key column depends on the entire primary key, not on part of it. Only a composite key can be violated this way.

Violation: columns that depend on half the key

crew_id flight_id role crew_name flight_number
101 7 captain Chen AA 100
102 7 first_officer Okafor AA 100
101 15 captain Chen DL 47

Primary key: (crew_id, flight_id)

  • crew_name depends on crew_id alone: a partial dependency
  • flight_number depends on flight_id alone: a partial dependency
  • role depends on the whole key, which crew member on which flight

"Chen" is stored once per assignment of crew 101.

2NF: move each partial dependency to its own table

crew

crew_id crew_name
101 Chen
102 Okafor

flights already holds flight_number

crew_assignments

crew_id flight_id role
101 7 captain
102 7 first_officer
101 15 captain
  • crew_name is stored once per crew member
  • role stays, because it depends on the pair

3NF: No Column Depends on Another Non-Key Column

Third normal form (3NF): in 2NF, and every non-key column depends directly on the primary key, not on some other non-key column.

Violation: a transitive dependency

flight_id flight_number origin origin_name origin_tz
7 AA 100 LAX Los Angeles Intl America/Los_Angeles
15 DL 47 ATL Hartsfield-Jackson America/New_York
23 UA 200 LAX Los Angeles Intl America/Los_Angeles
  • flight_id → origin: direct
  • origin → origin_name, origin_tz: direct, but origin is not the key
  • flight_id → origin → origin_name: transitive

The name and timezone are facts about the airport, not about the flight.

3NF: move the transitive dependency to its own table

flights

flight_id flight_number origin
7 AA 100 LAX
15 DL 47 ATL
23 UA 200 LAX

airports

airport_code name timezone
LAX Los Angeles Intl America/Los_Angeles
ATL Hartsfield-Jackson America/New_York
  • The airport’s name is stored once; a rename is one row
  • The airline schema is already in 3NF: each table’s columns depend on that table’s key and on nothing else

Normalization Splits One Wide Table into Four

  • 2NF takes crew_name out of the assignment rows and flight_number out of them too; the pair (crew_id, flight_id) keeps only role
  • 3NF takes origin_name out of the flight rows; the airport code stays as the reference
  • Each key ends up in two places: the table it identifies and the table that references it; every other column ends up in exactly one

Denormalization Trades Consistency for Fewer Joins

Normalization puts each fact in one place. Reading a fact together with its neighbors then needs a join.

Normalized: correct, join-dependent

SELECT b.booking_id, f.flight_number,
       a.name AS airport
FROM bookings b
JOIN flights f ON b.flight_id = f.flight_id
JOIN airports a ON f.origin = a.airport_code
WHERE b.passenger_id = 42;
  • Three tables joined
  • The airport’s name is stored once, so it is always consistent
  • Cost: CPU and I/O for the join; small at small scale, measurable at millions of rows and long join chains

Denormalized: redundant, join-free

SELECT booking_id, flight_number, origin_name
FROM bookings_denormalized
WHERE passenger_id = 42;
  • One table, no joins, faster reads
  • origin_name is repeated in every booking row
  • Cost: storage, more work per write, and the anomalies return; a rename touches every booking row, and a partial update leaves two names

The choice

  • Normalized: consistency guaranteed by the schema; reads pay for joins
  • Denormalized: faster reads; consistency becomes the application’s job

The airline schema already makes it once - flights.seats_remaining is a stored count that could be derived from aircraft.seat_capacity and the bookings; it is kept so that a seat check is one row read, and keeping it right under concurrent bookings is the coordination problem transactions exist for

Document Stores Denormalize by Design

A document database treats the denormalized form as the default rather than as an exception.

Relational, normalized

  • The schema enforces the structure
  • Joins reassemble related data at read time
  • A write touches one fact in one place
  • A read may need several joins
  • Spreading tables across machines is hard: a join needs its rows together

Any query can combine any tables, and the schema grows by adding tables and references. The cost is join work at scale.

Document, denormalized

  • Data is stored as self-contained documents
  • Related data is embedded in the document
  • No joins: one read returns everything one request needs
  • A write must update every copy of a duplicated fact
  • Spreading documents across machines is straightforward: each is independent

Each document holds everything one request needs. The cost is managing the copies when the same fact is embedded in thousands of documents.

Transactions and Concurrency

A Transaction Makes Several Writes One Operation

A booking is two writes: decrement the seat count, then insert the booking row. If the second fails, the first must be undone.

BEGIN;
  UPDATE flights SET seats_remaining = seats_remaining - 1
  WHERE flight_id = 7 AND seats_remaining > 0;

  INSERT INTO bookings (passenger_id, flight_id, fare_class)
  VALUES (42, 7, 'Y');
COMMIT;

BEGIN - starts the transaction

COMMIT - makes every change since BEGIN permanent, together

ROLLBACK - discards every change since BEGIN

  • Both writes take effect, or neither does
  • No other connection sees the state in between
  • The guarantee holds even if the process crashes halfway

Without a transaction

UPDATE flights SET seats_remaining = seats_remaining - 1
WHERE flight_id = 7;
-- application crashes here
INSERT INTO bookings ...;  -- never runs
  • The seat is decremented and no booking records why
  • Someone finds it later, or nobody does

The transaction boundary defines what “one operation” means to the database.

A Crash Mid-Transaction Rolls Everything Back

  • The UPDATE was applied in memory and logged; the INSERT never ran
  • On recovery the engine finds a transaction with no COMMIT record in the log and undoes its changes; seats_remaining is 50 again
  • Nothing the transaction did is visible to anyone, before or after the crash

ACID: Four Guarantees a Transaction Makes

Atomicity - all or nothing

  • The transaction succeeds and every change persists, or it fails (crash, error, ROLLBACK) and none does
  • No partial result is ever visible

Consistency - valid state to valid state

  • Every constraint is checked: foreign keys, CHECK, UNIQUE, NOT NULL
  • If any fails, the whole transaction rolls back
  • Rules the schema cannot express (“a pilot works at most 8 hours”) stay the application’s job

Isolation - concurrent transactions do not interfere

  • A’s uncommitted changes are invisible to B, at every level but the lowest
  • The result is as if the transactions ran one after another, while the engine runs them at the same time

Durability - committed data survives failures

  • Once COMMIT returns, the change is on stable storage: the write-ahead log entry was forced to disk before the acknowledgment
  • Power loss one millisecond after COMMIT, and the data is there on restart

The Engine Interleaves Concurrent Transactions

Several connections run transactions at the same time. The engine interleaves their statements, so another transaction’s write can land between one transaction’s read and its write.

  • Neither transaction saw the other, which is what isolation promised; the anomaly is in the read-then-write gap between two statements, and isolation levels decide how much of it the engine closes

Four Ways Concurrent Transactions Interfere

Dirty read - reading a change that is not committed

  • A updates a seat count and has not committed
  • B reads the updated value
  • A rolls back; B acted on a value that never existed

Non-repeatable read - one query, two answers

  • A reads seats_remaining = 5
  • B updates it to 3 and commits
  • A reads again and gets 3, inside the same transaction

Phantom read - rows that appear

  • A counts the bookings on flight 7: 48 rows
  • B inserts a booking on flight 7 and commits
  • A counts again: 49

Lost update - two writes from one read

  • A and B both read seats_remaining = 1
  • Both write 0 and commit
  • Two bookings, one seat

Each anomaly is one way the interleaving shows through. An isolation level is a statement of which of them the engine prevents.

Isolation Levels Set Which Anomalies Are Allowed

Four standard levels, each preventing more anomalies than the one before, and each costing more throughput.

Level Dirty read Non-repeatable read Phantom read Lost update
READ UNCOMMITTED possible possible possible possible
READ COMMITTED prevented possible possible possible
REPEATABLE READ prevented prevented possible possible
SERIALIZABLE prevented prevented prevented prevented

READ COMMITTED (the PostgreSQL default*)

  • Each statement sees only data committed before that statement began
  • Two statements in one transaction can see different data
  • Enough for most applications, with a guard on the write (next slide)

SERIALIZABLE

  • The result is as if the transactions ran one at a time
  • A transaction that would break that ordering fails at COMMIT and the application runs the whole transaction again; that retry is the cost
BEGIN ISOLATION LEVEL SERIALIZABLE;
  -- statements see one consistent snapshot
COMMIT;  -- may fail with a serialization error

The table is the SQL standard’s. PostgreSQL’s REPEATABLE READ is stricter*: it also prevents phantoms and turns a lost update into a serialization error.

A Guarded UPDATE Prevents the Double Booking

Under READ COMMITTED, an UPDATE checks its WHERE clause against the row as committed at the moment the UPDATE runs, not as it looked at the earlier SELECT.

BEGIN;
  SELECT seats_remaining FROM flights
  WHERE flight_id = 7;        -- returns 1

  -- another transaction commits here,
  -- setting seats_remaining to 0

  UPDATE flights
  SET seats_remaining = seats_remaining - 1
  WHERE flight_id = 7
    AND seats_remaining > 0;
  -- rows affected: 0

  -- application sees 0 rows: abort
ROLLBACK;

Optimistic concurrency

  • Read, compute, write with a condition that restates what the read assumed
  • If the condition fails, the assumption was stale; retry or give up
  • No lock is held between the read and the write

SELECT FOR UPDATE Locks the Row Before Reading It

The alternative to a guard is to take the writer’s lock at read time, so the value read is the value that will be written.

BEGIN;
  SELECT seats_remaining FROM flights
  WHERE flight_id = 7
  FOR UPDATE;  -- locks the row

  -- no other transaction can UPDATE,
  -- DELETE, or FOR UPDATE this row
  -- until this one commits

  UPDATE flights
  SET seats_remaining = seats_remaining - 1
  WHERE flight_id = 7;
  INSERT INTO bookings ...;
COMMIT;

What the lock does

  • Other writers to this row wait until COMMIT or ROLLBACK
  • Plain readers do not wait; they see the last committed version*
  • The lock is held for the whole transaction, so the transaction should be short

Guard or lock

  • Guard: no waiting, and a failed condition means a retry
  • Lock: no retry, and every other writer to the row queues behind it
  • Both are correct; the choice is how often writers collide

Writers to the Same Row Take Turns

Isolation is enforced with locks: markers on rows that decide which transactions must wait.

What takes a lock

  • UPDATE, DELETE, and SELECT FOR UPDATE lock the rows they touch
  • A plain SELECT takes no row lock; it reads the last committed version of the row and never waits for a writer*

Granularity

  • Row-level - one row; the rest of the table is untouched
  • Table-level - the whole table, taken by schema changes such as ALTER TABLE

Duration

  • Held until COMMIT or ROLLBACK
  • A long transaction holds its locks long, and everyone else waits

Contention

  • A updates flight 7, B updates flight 15: no contention, both proceed
  • A updates flight 7, B updates flight 7: B waits until A commits, then proceeds

The wait is the cost of isolation. Throughput depends on how often transactions touch the same rows and how long they hold them.

Deadlock: Two Transactions Wait for Each Other Forever

The sequence

  1. A locks flight 7
  2. B locks flight 15
  3. A asks for flight 15 and waits; B holds it
  4. B asks for flight 7 and waits; A holds it

Neither can proceed; without help, both wait forever.

What the engine does

  • Detects the cycle in the lock-wait graph
  • Aborts one transaction, the victim, and rolls its changes back
  • The other proceeds
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock
  on transaction 5678; blocked by process 5678.

What the application does

  • Treats the error as expected under row-level locking and retries the transaction
  • Locking rows in a consistent order (always the lower flight_id first) prevents most deadlocks

Indexes and Query Performance

Without an Index, Every Query Scans Every Row

SELECT * FROM flights WHERE origin = 'LAX';

With no index on origin, the engine has no way to reach the LAX rows except by examining every row.

  • Sequential scan - the plan when no index applies; every page of the table is read once
  • The cost follows the table size, not the number of matches: ten million rows take the same time whether 3 rows match or 3 million
  • The scan itself is not slow per row; it is the number of rows that makes it slow

B-Tree Indexes Reach Any Row in a Few Page Reads

An index is a separate structure kept alongside the table. The usual kind is a B-tree: a balanced, sorted tree whose leaves point at rows.

  • Sorted - serves equality (= 'LAX') and ranges (BETWEEN 'LAX' AND 'ORD'), and returns rows in key order
  • Balanced and wide - every leaf is at the same depth, and a page holds hundreds of keys, so ten million rows are three or four levels deep: three or four page reads to reach any key, about log₂ n ≈ 23 comparisons in total
  • Maintained automatically - every INSERT, UPDATE, and DELETE updates the index as well as the table

The Query Plan Changes When an Index Is Added

CREATE INDEX idx_flights_origin ON flights (origin);

EXPLAIN shows the plan the engine chose

Before the index:

EXPLAIN SELECT * FROM flights WHERE origin = ‘LAX’;
Seq Scan on flights (cost=0.00..230000.00 rows=300000 width=64)
  Filter: (origin = ‘LAX’)

After the index:

EXPLAIN SELECT * FROM flights WHERE origin = ‘LAX’;
Index Scan using idx_flights_origin on flights (cost=0.56..12000.00 rows=300000 width=64)
  Index Cond: (origin = ‘LAX’)

  • rows is the planner’s estimate of matching rows; cost is a unitless estimate of the work, used only to compare plans
  • EXPLAIN ANALYZE runs the query and adds the actual row counts

Same SQL, same data, different plan

  • The query text did not change, and neither did the application
  • The planner found the new index and switched from reading ten million rows to reading the matching ones
  • This is the payoff of declarative access: the engine can be given a faster path after the code is written

Two plan nodes are enough to read here

  • Seq Scan - read the whole table
  • Index Scan - follow the index, then fetch the matching rows

The Planner Skips the Index When Most Rows Match

  • An index scan costs one jump into the table per matching row; for 3% of ten million rows that is far less than reading the table
  • For 95% of the rows it would touch nearly every page in random order, so a single sequential pass is cheaper, and the planner takes it
  • Selectivity decides: CREATE INDEX makes the option available, and the planner uses its statistics on the column’s values to decide when the option pays

Every Write Pays for Every Index

What an index buys on reads

  • Equality lookups: a few page reads instead of a scan
  • Range queries: a contiguous slice of sorted leaves
  • ORDER BY on the indexed column: the rows come out sorted, no sort step
  • JOIN on the indexed column: a lookup per row instead of a scan per row

What it costs on writes and storage

  • Every INSERT adds an entry to every index on the table
  • Every UPDATE of an indexed column updates that index
  • Every DELETE removes an entry from every index
  • Each index is a sorted copy of its columns; a table with five indexes does six writes per row change

Index a column when

  • It appears in WHERE clauses
  • It is a join column (a foreign key)
  • It is sorted on (ORDER BY)
  • Its values are selective: few rows per value

Leave it unindexed when

  • The table is small; a scan is already cheap
  • The column is rarely queried
  • Its values are few (a boolean, a three-value status): the planner will not use it
  • The table is write-heavy and the reads can wait

An index on every column pays write cost and storage for indexes no query uses. Index selection follows the queries that actually run.

Column Order Decides What a Composite Index Serves

An index on several columns is sorted by the first column, then by the second within each value of the first.

CREATE INDEX idx_flights_origin_time
ON flights (origin, departure_time);

Queries this index serves

-- leading column
WHERE origin = 'LAX'

-- both columns
WHERE origin = 'LAX'
  AND departure_time >= '2026-02-12'
  AND departure_time <  '2026-02-13'

-- leading column, then a range
WHERE origin = 'LAX'
  AND departure_time BETWEEN '2026-02-12'
                         AND '2026-02-15'

Queries it does not serve

-- no value for the leading column
WHERE departure_time >= '2026-02-12'
  AND departure_time <  '2026-02-13'
  • The index is sorted by origin first; without an origin there is no entry point, and the planner reads the table instead

The order is a design decision

  • (origin, departure_time) serves “flights from LAX this week”
  • (departure_time, origin) serves “all flights today, any origin”
  • Same columns, different order, different queries served

Foreign Key Columns Need Indexes

Joins follow foreign keys. Without an index on the foreign-key column, every join through it scans the referencing table.

PostgreSQL does not index foreign-key columns on its own*

CREATE TABLE bookings (
    booking_id SERIAL PRIMARY KEY,
    flight_id INTEGER REFERENCES flights(flight_id),
    ...
);
-- flights(flight_id): indexed, it is the primary key
-- bookings(flight_id): no index
SELECT * FROM flights f
JOIN bookings b ON f.flight_id = b.flight_id
WHERE f.origin = 'LAX';
  • For each LAX flight, the engine scans every booking row to find the matches
  • 300 LAX flights on one day × 1,000,000 bookings = 300 million row comparisons

With an index on the foreign key

CREATE INDEX idx_bookings_flight_id
ON bookings (flight_id);
  • Each LAX flight becomes one index lookup into bookings: three or four page reads, about 20 comparisons
  • 300 flights × 20 = 6,000 comparisons, from 300 million, for the same query on the same data

The rule

  • Every foreign-key column gets an index; the write cost is small next to the join cost it removes
  • A parent delete under RESTRICT also checks the child table for references; without the index that check is a scan too
  • MySQL/InnoDB creates these indexes automatically; PostgreSQL does not, and a missing foreign-key index is a common cause of slow joins there

Scaling a Relational Database

Relational Guarantees Assume One Machine

A join, a transaction, and a foreign-key check each read rows from several tables in one step. The relational engine can promise what it promises because every row it needs is on the machine it runs on.

What depends on local data

  • Joins - matching rows across tables is a local read of both
  • Transactions - a commit covers every table at once because one log records them all
  • Foreign keys - a write to bookings checks a row in flights before it is accepted

So one database is one node

  • All tables on one disk, in one memory, behind one process
  • Growth means a bigger node, more connections into it, copies of it for reads, or splitting it at a cost

Every Connection Is a Process on the Server

A database connection is not a lightweight handle. PostgreSQL starts a backend process* for each one, with its own memory.

What one connection costs

  • Several megabytes of server memory for the process
  • Tens of milliseconds to open: TCP handshake, encryption setup, authentication
  • Opening one costs more than running a short query on it

How connections add up

  • 20 application containers × 20 connections each = 400 from one service
  • Three services on the same database: 1,200
  • Plus background workers, migration scripts, monitoring agents

The ceiling is set by the instance

  • Memory bounds the backends: max_connections is in the hundreds on a small instance, the low thousands on a large one
  • The limit is reached without any one service doing anything unreasonable

Connection Pools Reuse Open Connections

A connection pool keeps a set of open connections and lends one to each request. The connection is returned to the pool afterwards, not closed.

Application-side pool - each instance keeps its own

  • A request borrows a connection in well under a millisecond instead of opening one in tens of milliseconds
  • Every database library has one

What it does not solve

  • Connections at the database = instances × pool size: 50 containers × a pool of 10 = 500 backends, busy or not
  • Serverless functions are the extreme: thousands of short-lived instances, each wanting a connection

Pool too small - requests queue for a connection and latency spikes under load

Pool too large - the database runs out of memory for backends and every query slows down

The pool size is a concurrency limit chosen for the database, not for the application.

A Pooler Shares Few Connections Among Many Clients

An external pooler is a proxy between the applications and the database. Many application connections map onto a small number of database connections.

  • 1,000 application connections become 50 backends
  • A client holds a real backend only for a query or a transaction, then hands it back
  • The database sees a small, steady number of processes whatever the number of clients

Where it is needed

  • Many instances, or serverless functions, in front of one database
  • On AWS the managed form is RDS Proxy in front of an RDS instance; PgBouncer is the common self-hosted one

Bigger Hardware Has a Ceiling

Vertical scaling - the same database on a larger instance

  • More CPU cores: more queries at once
  • More memory: more of the working set cached, fewer disk reads
  • Faster storage: lower I/O latency

A large managed instance, db.r6g.16xlarge on RDS, has 64 vCPUs and 512 GB of memory and serves thousands of queries per second on millions of rows.

Two limits

  • Above the largest instance there is no bigger node to buy
  • Price grows with vCPU; throughput grows less, as lock contention, I/O bandwidth, and connection overhead take a larger share at each step

Read Replicas Scale Reads but Not Writes

Read replicas add capacity while keeping every relational guarantee.

  • One primary takes all writes
  • One or more replicas receive a copy of every write, asynchronously
  • Read traffic is spread over the replicas
  • Joins, foreign keys, and transactions all still work: each replica holds a complete copy

What replicas scale

  • Read throughput, roughly in proportion to their number
  • Isolation of heavy analytical queries from the primary
  • Read latency by geography, with a replica per region

What they do not scale

  • Write throughput: one primary takes every write
  • Storage: every replica holds the whole dataset

Replication lag - a replica is milliseconds to seconds behind the primary. A write followed at once by a read from a replica may not see that write; applications that read their own writes read them from the primary.

Sharding Splits the Data Across Machines

Sharding partitions the rows across machines, each holding one slice: flights from airports A to M on one node, N to Z on another. The single-node ceiling is gone, and so is the assumption every guarantee rested on.

What breaks

  • Joins across shards - the flight is on one node and the airport on another: a network round trip per matched pair
  • Transactions across shards - a commit spanning two nodes needs a coordination protocol: slower, more ways to fail
  • Foreign keys across shards - a node cannot check a reference it does not hold without asking the other node
  • Global uniqueness - a booking_id generated on one shard must not collide with one from another

All four are one fact: the guarantees assumed local data, and sharding removes it.

Not Every Workload Fits the Relational Model

Every guarantee has a price. An access pattern that never uses the guarantee still pays it.

Rigid schema

  • Every row has every column; a subtype with its own attributes means NULL-filled columns or extra tables
  • Changing the schema is an operational event: on 100 million rows, adding NOT NULL scans every row and changing a type rewrites every row, under a lock

Deep joins

  • A normalized itinerary is four tables; a page with a dozen related entities is a dozen joins
  • Join cost grows with the number and size of the tables

Queries that fight the model

  • Paths - “airports reachable within two connections” is a recursive join; each level multiplies the work
  • Text - WHERE notes LIKE '%mechanical%' cannot use a B-tree; every row is scanned
  • Aggregates over everything - summing 100 million rows reads every page, whole rows for two columns

Write-heavy, key-only access

  • Fetching and storing one record by its key pays for joins, constraints, and a planner it never uses

Each is the price of a guarantee; the question for a workload is whether it needs that guarantee.