
EE 547 - Unit 4
Spring 2026
Every application maintains state while running. A web server tracks active connections. An application server holds session data, cached computations, in-flight request context.
This state exists only in process memory.
Python dictionaries, object attributes, class instances — all reside in the process heap
A running container writes temporary files to its writable layer
Redis holds its dataset in RAM
Application logs accumulate in local buffers
When the process exits, the memory is reclaimed
When the container restarts, the writable layer is replaced
When the instance terminates, local storage is gone
This is by design — stateless processes are straightforward to scale, replace, and restart. But the data they operated on must exist somewhere that outlives any individual process:

A typical application runs multiple instances of the same service behind a load balancer. Each instance handles a fraction of incoming requests.
Shared state is the norm, not the exception.
Example: an airline booking system runs 20 application servers. All 20 must see the same seat map — a seat sold through one instance must immediately be unavailable to all others.
Each process cannot maintain its own copy. Copies diverge immediately under concurrent writes. Reconciling divergent copies is one of the hardest problems in distributed systems.

Data written to a persistent store must survive the failure of the process that wrote it — a stronger guarantee than “data is saved to a file.”
Process crash — the application segfaults mid-operation. A kill -9 terminates it without cleanup. Completed writes are still present when the replacement process starts.
Container restart — a deployment rolls out new code. The orchestrator terminates the old container and starts a new one. Data written by the old version is available to the new version without any migration step.
Host failure — the physical server loses power. The disk may have had buffered writes that never been persisted. After recovery, committed data is intact because the database wrote it to a transaction log before acknowledging the commit.
An application that writes to a local file and calls flush() has no guarantee the data reached disk — the OS may buffer it. Databases use write-ahead logging (WAL) to close this gap: the commit is not acknowledged until the log entry is on stable storage.

Every level of durability corresponds to a specific mechanism. Stronger guarantees cost more time.

The gap between process memory (~100 ns) and a cross-region replica (~100 ms) is six orders of magnitude. A write that takes nanoseconds locally takes hundreds of milliseconds to guarantee survival of a regional outage.
Higher durability adds latency cost.
An application that stores flight records as flat files — one JSON blob per flight, or a single CSV — must read and parse the entire dataset to answer any question about it.
File or object storage retrieves 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 know which object to retrieve. There is no mechanism to ask the storage system for objects matching a condition.
“Which flights from LAX have available seats?”
The cost scales linearly with total data size, regardless of how selective the question is.

The storage system itself understands column names, types, and constraints. This knowledge is what enables selective access.
SELECT flight_number, departure_time, seats_remaining
FROM flights
WHERE origin = 'LAX'
AND seats_remaining > 0
AND departure_date = '2026-02-12';The database reads only matching records. With an index on (origin, departure_date), this touches a handful of disk pages regardless of total table size.
10 million records, same question: microseconds, reading kilobytes.
The database can do this because it knows:
origin is a character column with an indexdeparture_date is a date type, comparable with =seats_remaining is an integer, comparable with >Without database-level structure, every application is responsible for:
Every application that accesses the data re-implements this logic. Each implementation is an opportunity for divergence.
Application A reads a flight record as JSON with departure_time as an ISO-8601 string. Application B reads the same file and parses it as a Unix timestamp. Both are “correct” until a timezone mismatch causes a booking for the wrong flight.
A database centralizes these responsibilities. The structure is defined once, enforced by the storage system, and consistent for every client that connects.
Multiple processes writing to the same data simultaneously is the default operating condition for any shared storage system.
An airline booking system. Two users attempt to book the last seat on flight 547, at the same time, through different application servers:
Two confirmed bookings. One seat.
This is the lost update — a concurrency anomaly that occurs whenever read-then-write operations are not atomic. Both servers read the same value, both compute the same result, and the second write silently overwrites the first.
The anomaly is not caused by a bug in application logic. The code is correct for a single-writer system. It fails because the system has multiple writers and no coordination between them.

One workaround is file locking — flock() on Unix, LockFileEx() on Windows. One writer at a time.
File-level locking:
This serializes all access. While one process holds the lock, every other process waits — including processes operating on completely unrelated flights.
A system with 10,000 flights and 100 concurrent users: every operation on every flight waits for a single global lock. Throughput falls.
Database row-level locking:
Databases provide granular locking — down to individual rows — combined with transaction isolation that controls what concurrent readers can see. The coordination cost is proportional to actual contention, not to dataset size.
Replicating granular locking and transaction isolation on top of file storage means re-implementing substantial portions of a database engine.
When the application controls how data is accessed, the access strategy is embedded in the code.
# Find available flights from LAX on Feb 12
results = []
for flight in all_flights: # iterate every record
if flight['origin'] == 'LAX' \
and flight['seats'] > 0 \
and flight['date'] == '2026-02-12':
results.append(flight)The strategy is fixed:
Changing access patterns requires changing code:
Every performance improvement is a code change. Every code change is a testing and deployment cycle.
SQL specifies what data to return, not how to find it.
SELECT flight_number, departure_time, seats_remaining
FROM flights
WHERE origin = 'LAX'
AND seats_remaining > 0
AND departure_date = '2026-02-12';The query optimizer evaluates execution strategies:
origin? Sequential scan.origin exists? Index lookup, then filter.(origin, departure_date)? Composite index lookup — reads only the exact set of matching rows.Same SQL. The optimizer picks the cheapest plan based on:
Performance improves without application changes.
An index is added on origin:
This separation — application specifies intent, database chooses execution — is what allows databases to optimize independently of the applications that use them. It is the central architectural insight of the relational model.
An aviation domain: airlines, airports, flights, aircraft, crews, passengers, and bookings.
Core entities:
Relationships:

A relational database stores data in tables. Each table has a fixed set of columns with defined names and types. Each row is one record conforming to that structure.
| 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 |
This is not a spreadsheet. The structure is enforced:
flight_number is not allowed (NOT NULL)departure_time value of "next Thursday" is rejected — the column expects a timestamp
Column types are not annotations — they are enforced constraints. The database rejects operations that violate them.
Common types:
CHAR(3))Type choice has consequences:
NUMERIC(10,2) for currency — floating-point arithmetic introduces rounding errors that are unacceptable for financial calculationsTIMESTAMP WITH TIME ZONE for departure times — a flight departing LAX at 08:00 Pacific is not the same instant as 08:00 EasternCHAR(3) for airport codes — fixed length enforces the IATA standardWhat the database rejects:
-- Type mismatch: text in an integer column
INSERT INTO flights (seats_total) VALUES ('many');
-- ERROR: invalid input syntax for type integer
-- Constraint violation: string too long for CHAR(3)
INSERT INTO flights (origin) VALUES ('Los Angeles');
-- ERROR: value too long for type character(3)
-- Precision overflow
INSERT INTO fares (price) VALUES (999999999.99);
-- ERROR: numeric field overflowThe database enforces these at write time, not read time. Invalid data never enters the table. Every application that queries this table can rely on seats_total being an integer and origin being exactly 3 characters.
Contrast with JSON in a file: any value can appear in any field, and validation is the caller’s responsibility.
A table with duplicate rows is ambiguous. Which row should be updated? Which row was deleted? Without identity, operations on individual records are undefined.
Primary keys provide identity.
A primary key is a column (or combination of columns) whose value uniquely identifies each row. The database enforces two rules:
CREATE TABLE airports (
airport_code CHAR(3) PRIMARY KEY,
name TEXT NOT NULL,
city TEXT NOT NULL,
timezone TEXT NOT NULL
);airport_code uniquely identifies each airport. Inserting a second row with 'LAX' is rejected. Inserting a row with no airport code is rejected.
Natural keys vs surrogate keys
A natural key uses data that already has meaning:
LAX, JFK) — stable, universally understoodA surrogate key is generated by the database:
1, 2, 3, ...)550e8400-e29b-41d4-a716-446655440000)Trade-offs:
flight_id = 7 tells you nothing about the flightData about different entities lives in different tables. A flight has an origin airport and a destination airport. Rather than duplicating the airport 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 contain a value that exists in airports.airport_codeorigin = 'XYZ' fails if no airport XYZ existsAttempting to insert an invalid reference:
What happens when the referenced row is deleted:
An airport is decommissioned. Flights still reference it. The database has three options, specified when the foreign key is created:
origin to NULL in referencing flightsEach represents a different business rule:
Without foreign keys: data duplicated in every 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 |
The airport name, city, and timezone are repeated for every flight from LAX. If LAX changes its name (it was renamed in 2017), every flight row must be updated. Miss one and the data is inconsistent.
With 10,000 flights from LAX, the name "Los Angeles International" is stored 10,000 times.
With foreign keys: stored once, referenced by code
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 |
Airport name stored once. Flights reference it by code. Name change: update one row in the airports table. All flights reflect the change immediately.
This principle — each fact stored once — is the foundation of normalization, developed in detail later.
Beyond keys and types, the database can enforce arbitrary conditions on data.
NOT NULL — a value must be provided
A flight without a flight number cannot exist. The database rejects the insert rather than storing an incomplete record.
UNIQUE — no duplicates allowed
No two aircraft can share a registration number. Unlike a primary key, a UNIQUE column can be NULL (an aircraft awaiting registration).
DEFAULT — value when none is provided
A new booking without an explicit status is automatically 'confirmed'.
CHECK — arbitrary conditions
The seat count cannot go negative. The database rejects any update that would set seats_remaining to -1. This prevents overselling at the storage layer, regardless of application logic.
A flight cannot arrive before it departs. This catches data entry errors, application bugs, and timezone conversion mistakes before they become corrupt records.
These rules are enforced regardless of which application writes the data. The booking web application, the crew scheduling system, and the batch data loader all face the same constraints. No application can bypass them.
Consider the alternative: every application validates independently.
seats_remaining >= 0 before confirming a bookingseats_remaining >= 0 before loading flight dataseats_remaining >= 0 before manual adjustmentsThree applications enforced the rule correctly. The fourth didn’t. The data is now corrupt, and the source of the corruption is difficult to trace.
With a CHECK constraint on the column, the fourth application’s bad write is rejected at the database level. The rule is defined once, in the schema, and enforced universally.

NULL is not zero. NULL is not an empty string. NULL is not false. NULL means the value is not known or does not apply.
When NULL is appropriate:
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 hasn’t departed yet has no actual_departure. The value is genuinely unknown — not zero, not “1970-01-01”, not a placeholder. NULL is the correct representation.
When NULL is not appropriate:
flight_number TEXT NOT NULL -- every flight has a number
origin CHAR(3) NOT NULL -- every flight has an originNOT NULL constraints prohibit NULL where absence would be nonsensical. A flight without an origin is not a flight.
NULL in expressions:
Any arithmetic or comparison with NULL yields NULL:
This is three-valued logic: TRUE, FALSE, NULL. Most languages have two truth values. SQL has three. This causes real bugs:
-- This finds NO rows where gate_number is NULL
SELECT * FROM flights WHERE gate_number = NULL;
-- This is 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 rows match — even the rows where gate_number is actually NULL.
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.0COUNT(*) counts rows — 5 rows existCOUNT(delay_minutes) counts non-NULL values — 3 values existAVG(delay_minutes) averages non-NULL values — (10 + 30 + 20) / 3 = 20.0, not (10 + 0 + 30 + 0 + 20) / 5COUNT(*) and COUNT(column) return different numbers on the same data. Treating NULL as zero in averages silently changes the result.
Logical operations:
FALSE AND NULL is FALSE because regardless of the unknown value, the result is FALSE. TRUE OR NULL is TRUE for the same reason. But TRUE AND NULL is NULL — the result depends on the unknown value.

Putting it together — a CREATE TABLE statement that exercises types, primary key, foreign keys, and constraints:
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, auto-generatedpassenger_id — must reference an existing passengerflight_id — must reference an existing flightConstraints as business rules:
fare_class is one of F (first), J (business), W (premium economy), Y (economy) — no other values acceptedstatus follows a defined lifecycle — no arbitrary strings(flight_id, seat_number) is UNIQUE — two passengers cannot occupy the same seat on the same flightseat_number can be NULL — seat assigned laterbooking_time defaults to the current timestampFour tables, connected by foreign keys. This is the working schema for the rest of the lecture.

Foreign key connections:
flights.origin → airports.airport_codeflights.destination → airports.airport_codeflights.aircraft_id → aircraft.aircraft_idbookings.flight_id → flights.flight_idbookings.passenger_id → passengers.passenger_id (table added later)What the schema enforces:
SQL is a declarative language. A query specifies the result — which rows, which columns, what conditions — and the database determines how to produce it.
SELECT flight_number, origin, departure_time
FROM flights
WHERE origin = 'LAX'
AND departure_time > '2026-02-12'
ORDER BY departure_time;What the query specifies:
flights tableWhat the query does not specify:
The database’s query optimizer makes these decisions based on table size, available indexes, and data distribution.
The core query pattern. Most SQL reads follow this structure.
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 -- multiple columns
SELECT DISTINCT origin -- unique values onlyFROM — which table (or tables) to read
WHERE — which rows to include
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 assignedEach condition is a predicate. Predicates combine with AND, OR, and NOT. The database evaluates them against each row and returns those where the overall expression is TRUE.
A query is written SELECT ... FROM ... WHERE ... GROUP BY ... ORDER BY, but the database evaluates clauses in a different order.

Why this matters:
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 set
ORDER BY departure_time -- ascending (default)
ORDER BY departure_time ASC -- explicit ascending
ORDER BY departure_time DESC -- descending
ORDER BY origin, departure_time -- sort by origin first,
-- then by time within originWithout ORDER BY, the database returns rows in whatever order is cheapest to produce. That order is not guaranteed and may change between executions.
LIMIT — restrict result count
LIMIT without ORDER BY returns an arbitrary subset — which 10 rows is undefined.
Combining them:
“The next 5 flights departing LAX” requires both:
Adding rows to a table.
INSERT INTO airports (airport_code, name, city, timezone)
VALUES ('LAX', 'Los Angeles International', 'Los Angeles', 'America/Los_Angeles');Column list is explicit — specifies which columns receive values, in what order. Columns with DEFAULT or NULL can be omitted.
-- booking_id is SERIAL (auto-generated)
-- booking_time has DEFAULT now()
INSERT INTO bookings (passenger_id, flight_id, fare_class)
VALUES (42, 7, 'Y');booking_id — auto-generated (SERIAL)booking_time — filled by DEFAULT (now())seat_number — set to NULL (no value provided)Multi-row insert:
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');Constraint enforcement at insert time:
-- Foreign key violation
INSERT INTO flights (origin) VALUES ('ZZZ');
-- ERROR: Key (origin)=(ZZZ) is not present in "airports"
-- Unique violation
INSERT INTO airports (airport_code, ...) VALUES ('LAX', ...);
-- ERROR: duplicate key value violates unique constraintConstraints are checked before the row enters the table.
Modifying or removing existing rows.
UPDATE — change column values in rows matching a condition
-- Reassign all flights from 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 if a CHECK constraint prohibits it.
DELETE — remove rows matching a condition
Foreign keys affect what can be deleted. Deleting an airport that flights reference is blocked (RESTRICT) unless the foreign key specifies CASCADE.
UPDATE and DELETE without WHERE:
These affect all rows in the table. No confirmation prompt, no undo. The WHERE clause is the only safeguard.
Each table holds one kind of entity. Relationships are expressed through foreign keys — IDs and codes that reference rows in other tables.
A single 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: passenger name, flight number, origin, destination, departure time — all in other tables.
To answer “passenger 42’s itinerary with flight details and airport names” requires combining four tables: bookings, flights, and airports (twice — origin and destination).

A JOIN combines rows from two tables based on a matching condition. For each row in the first table, the database finds rows in the second table where the condition holds, and produces a combined output row.
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 with booking rows for passenger 42JOIN flights f — for each booking, find the flight where flight_id matchesON b.flight_id = f.flight_id — the match condition (foreign key = primary key)
The ON clause specifies how rows from the two tables relate — typically a foreign key in one table matching a primary key in the other.
Without a join condition — the cross product:
Every booking paired with every flight. 1,000 bookings × 10,000 flights = 10 million rows. Almost all meaningless.
The ON clause filters the cross product down to only meaningful pairs — rows where the foreign key actually matches.
ON as a filter on the cross product:
| bookings.flight_id | flights.flight_id | match? |
|---|---|---|
| 7 | 7 | yes → include |
| 7 | 15 | no → discard |
| 7 | 23 | no → discard |
| 15 | 7 | no → discard |
| 15 | 15 | yes → include |
| 15 | 23 | no → discard |
6 possible pairs → 2 matches
In production, the optimizer never materializes the full cross product — it uses indexes and hash tables to find matches directly.
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 have no aircraft assigned (aircraft_id is NULL). The result has 480 rows. The 20 unassigned flights are excluded — no match in the aircraft table.

LEFT JOIN returns every row from the left table. Where a match exists in the right table, the columns are filled. Where no match exists, the right-side columns 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 assignment. The aircraft column is NULL for those rows.

Each JOIN adds one more table to the result, matching on its own condition. Chains of joins follow the foreign key relationships 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 join chain:
bookings — start with passenger 42’s bookingsflights — match each booking to its flight via flight_idairports as a_orig — resolve origin code to airport nameairports as a_dest — resolve destination code to airport nameThe airports table appears twice with different aliases — a flight references two different airports. Aliases (a_orig, a_dest) disambiguate 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 assembles data from four tables:
booking_id, fare, seat — from bookingsflight, departure — from flightsorigin, dest — from airports (two separate joins)
Each fact stored once — airport name in one row, flight details in one row — reassembled by joins at query time.
The cost of reassembly: CPU and I/O to match rows across tables. Indexes on join columns (foreign keys, primary keys) keep this cost proportional to result size, not table size.
Aggregation computes summary values across sets of rows.
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:
Without GROUP BY, aggregates operate on the entire result set. With GROUP BY, they operate per-group.
WHERE vs HAVING:
WHERE filters individual rows before grouping. HAVING filters groups after aggregation.
SELECT origin, COUNT(*) AS flight_count
FROM flights
WHERE departure_time > '2026-02-12' -- filter rows
GROUP BY origin
HAVING COUNT(*) > 30; -- filter groups“Airports with more than 30 flights tomorrow” — WHERE selects tomorrow’s flights, GROUP BY groups by origin, HAVING keeps only groups with more than 30.

7 rows → 3 summary rows. The detail is gone — only the aggregates remain. GROUP BY answers “how many” and “what’s the average” but discards individual row identity.
Aggregation and joins combine to answer questions that span multiple tables.
Bookings per airline for a given date:
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'
GROUP BY airline
ORDER BY total_bookings DESC;airline | total_bookings | flights
--------+----------------+--------
AA | 1247 | 23
UA | 983 | 18
DL | 871 | 21
Execution order:
COUNT(b.booking_id) — counts every booking row in the group
COUNT(DISTINCT f.flight_id) — counts unique flights only
23 flights, 1,247 bookings → ~54 passengers per flight
Complex questions often require composing queries — the result of one query becomes input to another.
Subquery — nested inside another 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:
CTE (Common Table Expression) — same logic, reads 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);WITH clause defines a named intermediate resultGROUP BY collapses rows — one output per group, detail discarded. Window functions compute across related rows while preserving every row 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 preserved. The rank is computed within each origin partition.

Common window functions:
Every query gets an execution plan. EXPLAIN reveals what the optimizer chose.
EXPLAIN SELECT flight_number, departure_time
FROM flights
WHERE origin = 'LAX' AND departure_time > '2026-02-12';Without an index on origin:
Seq Scan on flights Filter: (origin = ‘LAX’ AND departure_time > …) Rows Removed by Filter: 9700 Actual Rows: 300
Sequential scan — reads every row, discards non-matches. Cost proportional to table size.
With an index on origin:
Index Scan using flights_origin_idx on flights Index Cond: (origin = ‘LAX’) Filter: (departure_time > …) Actual Rows: 300
Index scan — navigates directly to LAX flights, then filters by date. Reads hundreds of rows instead of tens of thousands.
Same SQL, different plans. The optimizer switched strategy because an index became available. No application code changed.
Common plan nodes:
EXPLAIN ANALYZE — runs the query and reports actual times:
Planning Time: 0.2 ms Execution Time: 1.3 ms
SQL operates on existing tables. Someone decided those tables exist, chose the columns, defined the types, and drew the foreign key relationships.
Schema design is that prior step: given a domain (“airlines, flights, passengers, bookings”), decide:
The aviation schema from section 02 was presented as given. This section develops the process that produces schemas like it.
Entity-Relationship (ER) modeling is a design tool for this process.
CREATE TABLE statements are the implementationEntities — things with independent existence that the system needs to track.
Attributes — properties of an entity.
Relationships — associations between entities.

Cardinality specifies how many instances of one entity can be associated with instances of another. This determines how the relationship is implemented in tables.

One-to-many → foreign key in the “many” table
An airport has many flights. The foreign key goes in flights:
The foreign key lives in flights (the “many” side). The airport table has no reference back — the relationship is navigated from the many side.
One-to-one → foreign key with UNIQUE
CREATE TABLE registration_certs (
cert_id SERIAL PRIMARY KEY,
aircraft_id INTEGER UNIQUE
REFERENCES aircraft(aircraft_id),
...
);The UNIQUE constraint on aircraft_id enforces that no two certificates reference the same aircraft.
Many-to-many → junction table
Neither table can hold a single foreign key to the other — a crew member works multiple flights and a flight has multiple crew. A junction table represents the relationship:
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
(crew_id, flight_id) prevents duplicate assignmentsTotal participation — every instance of the entity must participate in the relationship.
NOT NULL on the foreign keyPartial participation — participation is optional.

Total participation → NOT NULL on the foreign key. Partial participation → nullable foreign key. The ER diagram captures the business rule; the DDL enforces it.
Most entities are identified by their own attributes — an airport by its code, an aircraft by its registration. Weak entities have meaning only within the context of a parent.
A flight leg — one segment of a multi-stop flight:
“Leg 1” alone is not unique — every multi-stop flight has a “leg 1.” The leg is only identifiable as leg 1 of flight AA 100.
CREATE TABLE flight_legs (
flight_id INTEGER REFERENCES flights(flight_id),
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 — identifies which flight (from parent)leg_number — identifies which leg within that flight (partial key)Defining characteristics:
Other examples: line items on an invoice, rooms within a building, episodes within a season.
Some entities are subtypes of a more general entity. Crew members are all employees with shared attributes (name, employee ID, hire date), but pilots have type ratings and cabin crew have language certifications.

Implementation options:
Single table with 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[], -- NULL for cabin crew
flight_hours INTEGER, -- NULL for cabin crew
languages TEXT[], -- NULL for pilots
service_cert TEXT -- NULL for pilots
);type_ratings is NULL for every cabin crew rowSeparate tables, shared 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
);Requirements (aviation booking system):
Entities identified:
Relationships identified:

Cardinality summary:
Implementation consequences:
crew_assignmentsEach entity becomes a table. Relationships become foreign keys or junction tables. Cardinality and participation determine constraints.
Direct mapping rules:
The aviation schema implements these rules:
-- Entity: Airport
CREATE TABLE airports (
airport_code CHAR(3) PRIMARY KEY, ...
);
-- Entity: Flight (1:N with Airport, total)
CREATE TABLE flights (
flight_id SERIAL PRIMARY KEY,
origin CHAR(3) NOT NULL
REFERENCES airports(airport_code),
aircraft_id INTEGER -- partial: 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)
);The ER diagram forces decisions that affect every query, every constraint, and every future schema change.
“Can a booking exist without a flight?”
flight_id nullable, LEFT JOINs needed, application must handle orphaned bookingsNOT NULL REFERENCES, database prevents orphaned bookings at write time“Can two passengers share a seat?”
UNIQUE(flight_id, seat_number) — enforced by the database, regardless of application logic“Is fare class a free-text field or an enumeration?”
"economy", "Economy", "Y", "econ")fare_class IN ('F','J','W','Y') — consistent, but requires schema change to add a new classThese decisions are difficult to change later.
NOT NULL to a column with existing NULLs → data migrationThe cost of changing schema grows with the codebase that depends on it. Queries, application logic, indexes, migrations — all encode assumptions about table structure.
The ER diagram is where these decisions are made explicitly — before they are encoded in DDL and depended on by application code.
A single table tracking crew assignments 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 airport name "Los Angeles Intl" appears in every row for every crew member on every flight from LAX.
This is not just wasted storage. It creates three categories of anomaly:
LAX. Miss one → inconsistent data.Update anomaly — partial update:
UPDATE crew_flights
SET origin_name = 'Los Angeles International'
WHERE origin = 'LAX';
-- Updates 2 of the 4 rows... application timeout
-- or WHERE clause was slightly wrongNow some rows say "Los Angeles Intl", others say "Los Angeles International". Same airport, two names. No constraint prevents this — no single source of truth.
Insert anomaly — forced coupling:
-- Want to add Denver International (DEN)
-- But the table requires crew_id and flight_id
INSERT INTO crew_flights (origin, origin_name)
VALUES ('DEN', 'Denver International');
-- ERROR: crew_id, flight_id cannot be NULLAn airport’s existence is coupled to a crew assignment existing — unrelated facts forced into one table.
The formal basis for normalization. A functional dependency X → Y means: if two rows have the same value of X, they must have the same value of Y.
In the aviation domain:
airport_code → name, city, timezone Knowing the airport code determines the name, city, and timezone. Two rows with LAX must agree on "Los Angeles Intl".
flight_id → flight_number, origin, destination, departure_time A flight ID determines all the flight’s attributes.
crew_id → crew_name, hire_date A crew member’s ID determines their name and hire date.
Dependencies in the redundant table:
| crew_id | crew_name | flight_id | flight_number | origin | origin_name |
|---|
crew_id → crew_name (crew attribute)flight_id → flight_number, origin (flight attribute)origin → origin_name (airport attribute)Three different entities mixed into one table, each with its own set of dependencies.

Transitive dependency: flight_id → origin → origin_name
The origin name depends on the origin code, not the flight. Storing it in the flights table duplicates it for every flight from that airport.
Each column holds a single atomic value. Each row is uniquely identifiable.
Violation — non-atomic values:
| crew_id | name | certifications |
|---|---|---|
| 101 | Chen | 737, 767, A320 |
| 102 | Okafor | 737 |
The certifications column holds a comma-separated list. Querying “which crew members are certified on 767” requires string parsing, not a simple comparison.
1NF — atomic values, separate rows or table:
Option 1: separate rows
| crew_id | name | certification |
|---|---|---|
| 101 | Chen | 737 |
| 101 | Chen | 767 |
| 101 | Chen | A320 |
| 102 | Okafor | 737 |
Option 2: separate table (better)
| crew_id | aircraft_type |
|---|---|
| 101 | 737 |
| 101 | 767 |
| 101 | A320 |
| 102 | 737 |
Eliminates the repeated crew_name and enables direct queries:
In 1NF, plus: every non-key column depends on the entire primary key, not just part of it.
2NF violations only occur with composite primary keys — single-column keys cannot have partial dependencies.
Violation:
| 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 only on crew_id — partial dependencyflight_number depends only on flight_id — partial dependencyrole depends on the full key (crew_id, flight_id) — correctResult: "Chen" stored in every assignment row for crew 101.
2NF — decompose partial dependencies:
crew:
| crew_id | crew_name |
|---|---|
| 101 | Chen |
| 102 | Okafor |
flights: (already has flight_number)
crew_assignments:
| crew_id | flight_id | role |
|---|---|---|
| 101 | 7 | captain |
| 102 | 7 | first_officer |
| 101 | 15 | captain |
crew_name stored once per crew member. role depends on the full composite key — which crew member on which flight.
In 2NF, plus: no non-key column depends on another non-key column. All non-key columns depend directly on the primary key.
Violation — 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 a key)flight_id → origin → origin_name (transitive)The airport name and timezone depend on the origin code, not on the flight. They belong in the airports table, not the flights table.
3NF — remove transitive dependencies:
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 |
Airport name stored once. "Los Angeles Intl" no longer duplicated across flights. Updating the name requires changing one row.
The aviation schema from section 02 is already in 3NF. Each table stores attributes that depend on that table’s primary key and nothing else.

Each step eliminates a class of dependency violation. The end state: each fact stored exactly once.
Normalization eliminates redundancy. But normalized data is distributed across tables, and reassembling it requires joins.
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. Airport name stored once — always consistent.
Cost: CPU and I/O for the join. Negligible at small scale; measurable at millions of rows with complex join graphs.
Denormalized — redundant, join-free:
One table, no joins, faster reads. But origin_name duplicated in every booking row.
Cost: storage, write complexity, anomaly risk. Updating an airport name means updating every booking row that references it — partial updates produce inconsistency.
The trade-off:
NoSQL databases make denormalization a first-class design principle rather than a reluctant optimization.
Relational (normalized):
Any query can combine any tables. Schema evolves by adding tables and relationships. The cost: join complexity at scale.
Document store (denormalized):
Each document contains everything needed to serve a request. The cost: managing redundancy and consistency across documents when the same fact is embedded in thousands of places.
A booking requires two writes: decrement the seat count, then insert the booking record. 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 all changes permanent
ROLLBACK — discards all changes since BEGIN
Without a transaction:
UPDATE flights SET seats_remaining = seats_remaining - 1
WHERE flight_id = 7;
-- application crashes here
INSERT INTO bookings ...; -- never executesThe transaction boundary defines what “one operation” means to the database.
Four guarantees that transactions provide. Each addresses a specific failure mode.
Atomicity — all or nothing
Consistency — valid state to valid state
Isolation — concurrent transactions don’t interfere
Durability — committed data survives failures

Multiple connections execute transactions simultaneously. The database interleaves their operations.

Both transactions read seats_remaining = 1. Both set it to 0. Both commit. The second transaction’s write overwrites the first — a lost update. Neither transaction saw the other’s changes.
Dirty read — reading uncommitted data
Non-repeatable read — same query, different results
seats_remaining = 5seats_remaining = 3 and commitsseats_remaining = 3Phantom read — new rows appear
Lost update — concurrent write collision
seats_remaining = 1seats_remaining = 1seats_remaining = 0, commitsseats_remaining = 0, commitsEach anomaly represents a different way concurrent transactions can interfere. Isolation levels control which anomalies the database prevents.
Four standard levels, each preventing a progressively larger set of anomalies. Higher isolation costs 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 | prevented |
| SERIALIZABLE | prevented | prevented | prevented | prevented |
READ COMMITTED (PostgreSQL default)
The seat booking under READ COMMITTED:
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 (condition no longer true)
-- application checks: no rows updated → abort
ROLLBACK;The WHERE seats_remaining > 0 guard prevents overselling — but only because the UPDATE sees the committed state at the time the UPDATE runs, not the state at the time of the SELECT.
Application-level patterns:
BEGIN;
SELECT seats_remaining FROM flights
WHERE flight_id = 7
FOR UPDATE; -- locks the row
-- guaranteed: no other transaction can
-- modify this row until we COMMIT
UPDATE flights
SET seats_remaining = seats_remaining - 1
WHERE flight_id = 7;
INSERT INTO bookings ...;
COMMIT;FOR UPDATE trades concurrency for correctness — other transactions reading this row must wait.
Databases enforce isolation through locks — markers that control which transactions can access which data.
Lock granularity:
Lock types:
Lock duration:
Contention:
Two transactions updating different flights:
Two transactions updating the same flight:

Lock wait time is the direct cost of isolation. Throughput depends on how often transactions contend for the same rows and how long they hold locks.
Two transactions, each holding a lock the other needs.

Sequence:
Neither can proceed. Both will wait forever.
Resolution:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock
on tuple (0,5) of relation "flights";
blocked by process 5678.
Deadlocks are not bugs — they are an expected consequence of fine-grained locking. Applications must handle the error and retry.
No index on origin. The database has no way to locate LAX flights without examining every row.

Sequential scan — the default when no index exists. Cost is proportional to table size, regardless of how many rows actually match. A 10-million-row table takes the same time whether the query matches 3 rows or 3 million.
An index is a separate data structure maintained alongside the table. The dominant type is the B-tree — a balanced, sorted tree that maps column values to row locations.

B-tree properties:
= 'LAX') and range queries (BETWEEN 'LAX' AND 'ORD')Before the index:
EXPLAIN SELECT * FROM flights WHERE origin=‘LAX’;
Seq Scan on flights Filter: (origin = ‘LAX’) Rows Removed by Filter: 9700 Actual Time: 185.3 ms
After the index:
EXPLAIN SELECT * FROM flights WHERE origin=‘LAX’;
Index Scan using idx_flights_origin Index Cond: (origin = ‘LAX’) Actual Time: 0.8 ms
Same query. Same data. 230x faster.
The optimizer decides whether to use the index.
WHERE origin = 'LAX' on a table where 3% of flights are from LAX → indexWHERE seats_remaining > 0 on a table where 95% of flights have seats → seq scan (faster to just read everything)The decision is automatic. The optimizer maintains statistics on value distributions and chooses the cheapest plan.
CREATE INDEX makes the option available. The optimizer decides when to use it.
Indexes are not free. Every index maintained is a cost paid on every write.
Benefits — read performance:
Costs — write performance and storage:
When to index:
When not to index:
A table with an index on every column pays write overhead and storage cost for each — including indexes no query ever uses. Index selection should be driven by actual query patterns.
An index on multiple columns. Column order determines which queries it can serve.
The index sorts by origin first, then by date within each origin.
Queries this index serves:
Queries this index does NOT serve:
The index is sorted by origin first — without a value for origin, there is no efficient entry point. The database must scan the entire index, no better than a seq scan.
Column order is a design decision driven by query patterns:
(origin, departure_date) — serves “flights from LAX this week”(departure_date, origin) — serves “all flights today from any airport”Same columns, different ordering, different queries served efficiently.
Joins traverse foreign key relationships. Without an index on the FK column, every join becomes a sequential scan of the referenced table.
PostgreSQL does not automatically index foreign key columns.
CREATE TABLE bookings (
booking_id SERIAL PRIMARY KEY,
flight_id INTEGER REFERENCES flights(flight_id),
...
);
-- flights(flight_id) has a PK index
-- bookings(flight_id) has NO indexFor each LAX flight, the database scans every row in bookings to find matches:
With an index on the foreign key:
Each LAX flight does an index lookup into bookings:
Every foreign key column should generally have an index. The write cost of maintaining it is almost always justified by join performance.
MySQL/InnoDB creates FK indexes automatically. PostgreSQL does not — FK index omissions are a frequent source of production performance issues in PostgreSQL deployments.
Every row in a table has the same columns. A flight has an origin, a destination, a departure time — regardless of whether the flight is domestic or international, cargo or passenger, scheduled or charter.
Adding a column affects every row.
All existing flights now have customs_required = NULL — the column exists for every row, including domestic flights where it has no meaning. Charter flights may need columns that scheduled flights do not.
Heterogeneous entities fit awkwardly in fixed schemas. When subtypes have substantially different attributes:
ALTER TABLE in production is an operational event.
On a table with 100 million rows:
Schema changes require coordination with application code:
Schema migration tools (Flyway, Alembic, Django migrations) manage this — versioned scripts applied in sequence, with rollback capability.
The same property that makes schema evolution expensive is what prevents invalid data.
Rigid schema guarantees:
NOT NULL enforcedCHAR(3) enforcedThese guarantees hold for every row, every application, every access path. No application can insert a flight without an origin. No background job can set a seat count to -1.
Flexible schema trades this for adaptability:
A document store accepts any structure:
Different fields, no schema violation — because there is no schema.
"origin": 123 or a missing flight_numberNormalized data eliminates redundancy by distributing facts across tables. Retrieving a complete record requires joining those tables back together.
A passenger’s itinerary:
SELECT b.booking_id, f.flight_number,
f.departure_time,
a_orig.name, a_dest.name
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;Four tables joined. With proper indexes, this runs in milliseconds even at millions of rows.
But join cost is not constant. It depends on:

At small scale, joins are negligible. At large scale with many tables, they dominate query time — intermediate result sizes compound across each join.
Hierarchical data — “all airports reachable within 2 connections from LAX”:
WITH RECURSIVE reachable AS (
SELECT destination, 1 AS hops
FROM flights WHERE origin = 'LAX'
UNION
SELECT f.destination, r.hops + 1
FROM flights f
JOIN reachable r ON f.origin = r.destination
WHERE r.hops < 2
)
SELECT DISTINCT destination FROM reachable;Each recursion level multiplies the working set — three hops from a hub airport can touch millions of rows.
Full-text search — “flights with ‘mechanical’ in maintenance notes”:
% prefix prevents index use — sequential scan of every row. PostgreSQL has full-text search extensions (tsvector, tsquery), but these are add-ons, not native relational operations.
Analytical aggregation over large datasets:
SELECT origin, date_trunc('month', departure_time),
COUNT(*), AVG(seats_remaining)
FROM flights
GROUP BY origin, date_trunc('month', departure_time);The relational model assumes all data resides on one machine. Cross-table joins, multi-table transactions, and foreign key enforcement all depend on local data access.
Vertical scaling — bigger hardware:
A large managed instance (db.r6g.16xlarge on RDS): 64 vCPUs, 512 GB RAM, provisioned IOPS. Handles millions of rows, thousands of queries per second.
Vertical scaling has a ceiling. The largest available instance is a fixed upper bound. Cost scales faster than capacity — doubling CPU and RAM more than doubles the price.

The 16xl is 32x the cost of the large for 32x the CPU — but real-world throughput does not scale 32x. Lock contention, I/O bottlenecks, and connection overhead erode gains at each tier.
A database connection is not a lightweight handle. PostgreSQL forks a dedicated backend process for each connection — a separate OS process with its own memory allocation.
Per-connection cost:
Connections accumulate across services.
A db.r6g.xlarge (32 GB RAM) with 5–10 MB per backend has a practical ceiling around 1,000–2,000 concurrent connections before memory pressure degrades query performance for every client.

A connection pool maintains a set of open connections and lends them to application threads on demand. The connection is returned after use, not closed — avoiding repeated setup overhead.
Application-side pool — each application instance manages its own pool (HikariCP, SQLAlchemy create_engine(pool_size=...), node-postgres Pool).
External connection pooler — a proxy between application and database (PgBouncer, RDS Proxy). Many application connections map to fewer database connections.
Pool sizing:
connections = (2 × CPU cores) + spindles for the database host, divided across clients
Distributing data across multiple machines (sharding) removes the single-node ceiling — but also removes the assumptions the relational model depends on.

What sharding breaks:
Joins across shards — flights on Shard A, airports on Shard B. Join requires network round trip; data must be shipped between machines.
Cross-shard transactions — require distributed coordination (two-phase commit). Slower, more failure modes, not universally supported.
Foreign key enforcement — Shard A cannot verify a FK references a row on Shard B without querying Shard B on every write.
Global uniqueness — ensuring a unique booking_id when inserts happen on any shard is a distributed consensus problem.
All four are consequences of data locality. The relational model’s guarantees assume all data is locally accessible.
Read replicas scale reads while preserving relational guarantees.
Scales:
Does not scale:

Replication lag — replicas receive writes asynchronously, typically milliseconds to seconds behind the primary. A write followed by an immediate read from a replica may not see its own write — a common source of confusing application behavior.
When a system runs on multiple machines connected by a network, three properties are in tension.

Consistency — every read returns the most recent write. All nodes see the same data at the same time.
Availability — every request receives a response, even if some nodes are unreachable.
Partition tolerance — the system continues operating when network links between nodes fail.
Network partitions are inevitable in distributed systems — hardware fails, switches drop packets, links go down. A system must tolerate partitions, leaving a choice between consistency and availability during a partition:
The choice depends on the domain. A banking system cannot tolerate stale balances. A content feed can tolerate a post appearing seconds late.
What the relational model provides:
The application does not validate types, enforce relationships, coordinate concurrent access, or manage crash recovery. The database handles these — which simplifies application code but constrains the system’s architecture.
What these choices cost:
Each cost is the direct consequence of a guarantee. Systems that offer flexible schemas, join-free reads, or horizontal scaling achieve them by shifting the corresponding responsibility to application code.