
EE 547 - Unit 4
Fall 2026
Every running process holds state in memory. Every restart clears it.
What a restart clears
What must outlive the process
Where it lives instead

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
One seat map for twenty servers
A private copy per instance fails
Coordination happens at the store

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
kill -9 ends it without cleanupContainer restart
Host failure
Write-ahead log (WAL)
flush() is not on disk; the operating system buffers itfsync) before it acknowledges the commit
Each level of durability is a mechanism, and each mechanism adds a wait before the write is acknowledged.

What each level waits for
Six orders of magnitude end to end
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
“Which flights from LAX have seats?”
The cost scales with the size of the dataset, however selective the question.

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.
What the schema gives the engine
origin is a three-character column, and it has an indexdeparture_time is a timestamp, so >= compares instantsseats_remaining is an integer, so > 0 is a numeric testWhat the query reads
(origin, departure_time), a few pages, whatever the table sizeWithout structure in the store, each application re-implements it
Each implementation can diverge
departure_time as an ISO-8601 stringA database defines the structure once and enforces it for every client that connects.
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:
Two confirmed bookings for one seat.
Lost update
Not an application bug

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
Everything waits
Row lock - one writer for one row
Waiting happens only on the same row
Rebuilding row-level locking on top of files means re-implementing a large part of a database engine.
Procedural - the strategy is in the code
Every performance change is a code change, with its own test and deployment cycle.
Declarative - the strategy is the engine’s
origin switches the plan from ten million rows scanned to a few hundred read, with no application changeThis is data independence: the application states the result, the engine chooses the access path.
Airlines, airports, flights, aircraft, crews, passengers, and bookings.
Entities
Relationships

What the schema must handle
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
What is rejected

Column types are enforced constraints. The database rejects a write that violates them.
Common types
CHAR(3))Type choice has consequences
NUMERIC(10,2) for currency: floating-point arithmetic rounds, and a ledger cannotTIMESTAMP WITH TIME ZONE for departures: 08:00 Pacific and 08:00 Eastern are different instantsCHAR(3) for airport codes: the fixed length enforces the IATA formWhat 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 rangeseats_remaining being an integer and origin being three charactersA 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:
airport_code identifies each airport'LAX' is rejected
Natural key - data that already has meaning
LAX, JFK): stable, universally understoodSurrogate key - generated by the database
SERIAL* (*PostgreSQL syntax and behavior, here and wherever marked)550e8400-e29b-41d4-a716-446655440000)flight_id = 7 says nothing about the flightCommon practice
aircraft_idData 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_codeorigin = 'XYZ' is rejected if no airport XYZ existsWhen the referenced row is deleted - shown for the nullable aircraft_id

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 |
"Los Angeles International" 10,000 timesorigin_* columns must agree from row to rowWith 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 |
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 insert is rejected rather than an incomplete record stored.
UNIQUE - no duplicates
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
A new booking without an explicit status is 'confirmed'.
CHECK - an arbitrary condition
The seat count cannot go negative. An update that would set it to -1 is rejected, whatever the application logic that produced it.
A flight cannot arrive before it departs. This catches data-entry errors, application bugs, and timezone conversion mistakes before they become rows.
Without a constraint in the schema, every application validates on its own.
Every writer validates on its own
seats_remaining >= 0 before confirming a bookingOne writer skips the check
One CHECK constraint on the column

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
actual_departure; the value is unknown, not zero and not a placeholder dateWhere NULL is prohibited
NULL in expressions
Any arithmetic or comparison with NULL yields NULL:
The first query compares each row’s gate_number with NULL. The comparison yields NULL, which is not TRUE, so no row matches.
Aggregation with NULLs
COUNT(*) counts rows: 5 rows existCOUNT(delay_minutes) counts non-NULL values: 3AVG(delay_minutes) averages non-NULL values: (10 + 30 + 20) / 3 = 20.0, not (10 + 0 + 30 + 0 + 20) / 5 = 12COUNT(*) 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 falseTRUE OR NULL is TRUE for the same reasonTRUE AND NULL is NULL: the result depends on the unknown value
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, 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)status follows a declared lifecycle; no other strings(flight_id, seat_number) is UNIQUE: two passengers cannot hold one seat on one flightseat_number can be NULL; a seat is assigned laterbooking_time defaults to the time of the insertAirports, 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
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
CREATE TABLE, ALTER TABLE, CREATE INDEX: the schemaINSERT, UPDATE, DELETESELECT, with joins, grouping, and orderingThe engines that speak it
Dialects
SELECT, joins, aggregates, transactions) is shared and is what this lecture teachesSERIAL), casts (::date), some types and functionsWhy one language matters
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.
%s is sent separately from the text, so a value is never spliced into the query
CREATE TABLE to a six-table joinMost reads have this shape. The query says what to return; how to find it is the engine’s decision.
SELECT - which columns to return
FROM - which table, or tables, to read
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 assignedAND, OR, and NOTA query is written SELECT ... FROM ... WHERE ... GROUP BY ... ORDER BY. The engine evaluates the clauses in a different order.

What follows from the order
ORDER BY - sort the result
LIMIT - restrict the count
Both together
“The next 5 flights departing LAX” needs a sort and a limit:
The column list is explicit
booking_id - generatedbooking_time - filled by the DEFAULTseat_number - NULL; no value was givenSeveral rows in one statement
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 constraintUPDATE - change columns in the rows that match
seats_remaining to -1 is rejected by the CHECKDELETE - remove the rows that match
Without WHERE, every row
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”

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.
FROM bookings b - start from the booking rowsJOIN flights f - for each booking, find the flightON b.flight_id = f.flight_id - the match condition: foreign key equals primary key
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
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 |
The default JOIN, also written INNER JOIN, includes a row in the result only if a match exists in both tables.
flight_number | aircraft
--------------+-----------------
AA 100 | Boeing 737-800
UA 512 | Airbus A320
DL 47 | Boeing 767-300
aircraft_id is NULL)
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.
flight_number | aircraft
--------------+-----------------
AA 100 | Boeing 737-800
UA 512 | Airbus A320
DL 47 | Boeing 767-300
SW 220 | NULL
aircraft column is NULL for those 20 rows
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
bookings - passenger 42’s bookingsflights - each booking’s flight, by flight_idairports as a_orig - the origin code resolved to a nameairports as a_dest - the destination code resolved to a namebooking | 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 bookingsflight, departure - from flightsorigin, dest - from airports, two separate joins
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, an aggregate covers the whole result; with GROUP BY, one value per group.
WHERE filters rows, HAVING filters groups

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
Two counts on the same rows
COUNT(b.booking_id) counts every pair in the group: one per bookingCOUNT(DISTINCT f.flight_id) counts each flight once, however many bookings it hasA 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:
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);WITH clause defines a named intermediate resultGROUP BY collapses a group to one row. A window function computes over the rows of a group while every row stays in the result.
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
PARTITION BY is the grouping; ORDER BY inside OVER is the order the function sees
Other window functions
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:
Entity-Relationship (ER) modeling is the design tool:
CREATE TABLE statements are its implementationEntities - things with an existence of their own that the system tracks
Attributes - properties of an entity
Relationships - associations between entities


One-to-many: foreign key in the “many” table
An airport has many flights, so the reference lives in flights:
One-to-one: foreign key plus UNIQUE
aircraft_id means no two certificates name the same aircraftMany-to-many: a junction table
Neither table can hold one foreign key to the other. A junction table holds one row per pair:
crew_id | flight_id | role
--------+-----------+---------
101 | 7 | captain
102 | 7 | first_officer
101 | 15 | captain
103 | 15 | first_officer
(crew_id, flight_id) prevents a duplicate assignmentTotal participation - every instance takes part in the relationship
NOT NULL on the foreign keyPartial participation - taking part is optional
The diagram records 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. A weak entity has an identity only inside its parent.
A flight leg, one segment of a multi-stop flight:
“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 parentleg_number - which leg within that flightWhat makes it weak
Other examples: line items on an invoice, rooms in a building, episodes in a season.
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
);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
);Requirements for the booking system
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
Every relationship line in the diagram comes from one of these sentences.

Cardinalities
What the diagram already decides
crew_assignmentsMapping rules
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)
);The diagram forces decisions that every query, every constraint, and every later schema change depends on.
Can a booking exist without a flight?
flight_id nullable, LEFT JOINs everywhere, orphaned bookings handled in application codeNOT NULL REFERENCES, and the database refuses an orphan at write timeCan two passengers share a seat?
UNIQUE(flight_id, seat_number), enforced whatever the application doesIs fare class free text or an enumeration?
"economy", "Economy", "Y", "econ")fare_class IN ('F','J','W','Y'): consistent, and a new class is a schema changeThese decisions are expensive to reverse
The cost grows with the code that depends on the schema
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 LAXThree anomalies
LAX must change, and a missed row leaves the table disagreeing with itselfUpdate anomaly: a partial update
Insert anomaly: an unrelated fact cannot enter
Delete anomaly: a fact leaves with an unrelated row

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 nameflight_id → flight_number, origin, destination, departure_timecrew_id → crew_name, hire_dateDependencies inside the wide table
| crew_id | crew_name | flight_id | flight_number | origin | origin_name |
|---|
crew_id → crew_name: a crew attributeflight_id → flight_number, origin: flight attributesorigin → origin_name: an airport attributeTransitive dependency
flight_id → origin → origin_name
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 listSecond 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 dependencyflight_number depends on flight_id alone: a partial dependencyrole 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 memberrole stays, because it depends on the pairThird 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: directorigin → origin_name, origin_tz: direct, but origin is not the keyflight_id → origin → origin_name: transitiveThe 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 |

crew_name out of the assignment rows and flight_number out of them too; the pair (crew_id, flight_id) keeps only roleorigin_name out of the flight rows; the airport code stays as the referenceNormalization puts each fact in one place. Reading a fact together with its neighbors then needs a join.
Normalized: correct, join-dependent
Denormalized: redundant, join-free
origin_name is repeated in every booking rowThe choice
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
A document database treats the denormalized form as the default rather than as an exception.
Relational, normalized
Any query can combine any tables, and the schema grows by adding tables and references. The cost is join work at scale.
Document, denormalized
Each document holds everything one request needs. The cost is managing the copies when the same fact is embedded in thousands of documents.
A booking is two writes: decrement the seat count, then insert the booking row. If the second fails, the first must be undone.
BEGIN - starts the transaction
COMMIT - makes every change since BEGIN permanent, together
ROLLBACK - discards every change since BEGIN
Without a transaction
The transaction boundary defines what “one operation” means to the database.

seats_remaining is 50 againAtomicity - all or nothing
Consistency - valid state to valid state
Isolation - concurrent transactions do not interfere
Durability - committed data survives failures
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.

Dirty read - reading a change that is not committed
Non-repeatable read - one query, two answers
seats_remaining = 5Phantom read - rows that appear
Lost update - two writes from one read
seats_remaining = 1Each anomaly is one way the interleaving shows through. An isolation level is a statement of which of them the engine prevents.
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*)
SERIALIZABLE
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.
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

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.
What the lock does
Guard or lock
Isolation is enforced with locks: markers on rows that decide which transactions must wait.
What takes a lock
Granularity
Duration
Contention

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

The sequence
Neither can proceed; without help, both wait forever.
What the engine does
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock
on transaction 5678; blocked by process 5678.
What the application does
With no index on origin, the engine has no way to reach the LAX rows except by examining every row.

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.

= 'LAX') and ranges (BETWEEN 'LAX' AND 'ORD'), and returns rows in key orderEXPLAIN 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 plansEXPLAIN ANALYZE runs the query and adds the actual row countsSame SQL, same data, different plan
Two plan nodes are enough to read here

CREATE INDEX makes the option available, and the planner uses its statistics on the column’s values to decide when the option paysWhat an index buys on reads
What it costs on writes and storage
Index a column when
Leave it unindexed when
An index on every column pays write cost and storage for indexes no query uses. Index selection follows the queries that actually run.
An index on several columns is sorted by the first column, then by the second within each value of the first.
Queries this index serves
Queries it does not serve
origin first; without an origin there is no entry point, and the planner reads the table insteadThe order is a design decision
(origin, departure_time) serves “flights from LAX this week”(departure_time, origin) serves “all flights today, any origin”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*
With an index on the foreign key
The rule
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
bookings checks a row in flights before it is acceptedSo one database is one node

A database connection is not a lightweight handle. PostgreSQL starts a backend process* for each one, with its own memory.
What one connection costs
How connections add up
The ceiling is set by the instance
max_connections is in the hundreds on a small instance, the low thousands on a large one
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
What it does not solve
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.
An external pooler is a proxy between the applications and the database. Many application connections map onto a small number of database connections.
Where it is needed

Vertical scaling - the same database on a larger instance
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

Read replicas add capacity while keeping every relational guarantee.
What replicas scale
What they do not scale

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 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
booking_id generated on one shard must not collide with one from anotherAll four are one fact: the guarantees assumed local data, and sharding removes it.
Every guarantee has a price. An access pattern that never uses the guarantee still pays it.
Rigid schema
Deep joins
Queries that fight the model
WHERE notes LIKE '%mechanical%' cannot use a B-tree; every row is scannedWrite-heavy, key-only access
Each is the price of a guarantee; the question for a workload is whether it needs that guarantee.