Database Management Systems (DBMS) Module 3 – Conceptual design with the ER model
ER case study: a train reservation system
Design the ER model of a train reservation system from a brief, sell berths over ranges of stops, and test the PostgreSQL schema with sample bookings.
What you will learn
- Translate an original requirements brief into entities, relationships and constraints
- Resolve design issues: entity vs attribute, entity vs relationship, binary vs ternary, history
- Model segment-based seat availability so one seat can be sold for non-overlapping journeys
- Map the final design to PostgreSQL DDL and test it with sample bookings
Before you start
What you’ll learn · Before you start
What you will learn
- Translate an original requirements brief into entities, relationships and constraints
- Resolve design issues: entity vs attribute, entity vs relationship, binary vs ternary, history
- Model segment-based seat availability so one seat can be sold for non-overlapping journeys
- Map the final design to PostgreSQL DDL and test it with sample bookings
Before you start
On this page
This case study designs the database of a train reservation system, from a written brief to tested PostgreSQL tables. One requirement sets it apart from the designs earlier in this module: a berth is not sold once per journey of the train but over a range of stops, so the same berth can carry one passenger from the first stop to the fourth and another from the fourth to the last. The design records each journey as a range of stop numbers and lets the database itself refuse two overlapping journeys on one berth. On the way it settles the questions every ER design meets: entity or attribute, entity or relationship, binary or ternary, and how to keep history instead of overwriting it. Each step runs: in SQLite in your browser, and on a throwaway PostgreSQL 18.6 server whose output is shown.
Not affiliated
Based on public information about Indian Railways. MySmartCoPilot is not affiliated with Indian Railways, and Indian Railways’s real booking system may differ from what this lesson describes.
The brief
The brief is written for this lesson. It is inspired by how long-distance trains are booked, but every rule, number and name in it is our own:
Brief
A rail operator runs long-distance trains. Each train has a number and a name and follows a fixed route, an ordered list of stops, each at a station. A train runs on many dates, and each run has its own coaches; a coach has a code such as B1, a class and numbered berths. A passenger books a journey on one run, from one stop to a later stop. A booking carries up to six passengers and gets a reference for looking it up. Each passenger either gets a berth for the whole journey or joins the waitlist of that class on that run; when a cancellation frees a berth, the first waitlisted passenger whose journey fits takes it. The operator must be able to say how many berths are free between any two stops of a run, and every change to a booking must be kept. Fares, payments, quotas, refunds and seat preferences are out of scope.
Entities, keys and the weak ones
Reading the brief with the questions of the first lesson gives these entity sets:
- STATION, keyed by a station code, and TRAIN, keyed by its number: strong entity sets.
- STOP, a place on a train’s route. A stop number such as 3 means something only for one train, so STOP is a weak
entity of TRAIN with the partial key
seq, and it is related to the STATION it is at (N:1, total). - RUN, one train on one date: a weak entity of TRAIN with the partial key
run_date. - COACH, a weak entity of RUN with the partial key
coach_code, and BERTH, a weak entity of COACH with the partial keyberth_no, because the composition of a train can change from one run to the next. - BOOKING, a strong entity with a reference, and PASSENGER, a weak entity of BOOKING with the partial key
passenger_no, from 1 to 6.
A chain of weak entities makes long keys: a berth would be identified by train, date, coach code and berth number.
The tables therefore give RUN a surrogate key, run_id, and keep (train_no, run_date) as a UNIQUE alternate key;
COACH and BERTH stay weak below it, so a berth is (run_id, coach_code, berth_no). That is a choice made in the mapping,
not in the ER model, and it is made once, at the top of the chain.
Design decisions
Most of a design’s quality comes from a handful of decisions, each of which has a wrong answer that looks fine at first:
- Is a station an attribute of a stop, or an entity? An entity. Stations have their own code and name and are shared by many trains; as text in every stop they would be spelt several ways.
- Is a stop a relationship between TRAIN and STATION, or an entity? A weak entity. Bookings must refer to stops, and a route can pass one station twice; a relationship instance could do neither cleanly.
- Is a booking a relationship between PASSENGER and RUN, or an entity? An entity. It has its own reference, its own journey and its own history, and several passengers share it.
- Is a berth allocation binary or ternary? Binary, from PASSENGER to BERTH, with the journey as its attribute. The passenger already fixes the booking, the run and the journey, so a ternary with BOOKING would repeat what the passenger implies.
- How is history kept? As appended events. Overwriting a status loses who was waitlisted, confirmed or cancelled, and when.
- What does the passenger see? A random reference, not the internal key. Sequential numbers reveal booking volumes and invite guessing; the internal key stays the primary key.
The last row deserves a tool. A reference like K7Q2M9XW is eight characters from 36, about 2.8 × 10¹² possible
values, enough that a guessed reference almost never belongs to anyone; UNIQUE catches the rare collision when one is
generated.
A berth over a range of stops
Number the stops of a route 1, 2, 3 and so on. A journey from stop f to stop t occupies the berth on every segment
between them, so it is stored as the half-open range [f, t): it includes stop f and excludes stop t. Two journeys
on the same berth clash exactly when each starts before the other ends: f1 < t2 AND f2 < t1. Half-open ranges make
the common case right without special code: Anu travels [1,4) and leaves at stop 4, where Ravi boards for [4,7), and
the two journeys do not overlap.
Journeys as ranges of stops
Text description of the diagram
A grid with the stops 1 to 7 from left to right and one row per berth of coach B1. Each sold journey is a bar from the stop where it starts to the stop where it ends.
1. B1-21: Tom, stops 2 to 6. 2. B1-22: Sara, stops 3 to 5. 3. B1-23: Anu, stops 1 to 4, then Ravi, stops 4 to 7, starting where Anu's bar ends. Below them a dashed bar for Sara, stops 3 to 5, marked refused, because it overlaps both. 4. B1-24: no journey.
A shaded column marks the journey asked about, stops 5 to 7. Berths 21 and 23 are taken over it; berths 22 and 24 are free.
SQLite has no range type, so the browser version stores the two ends in two columns and refuses overlaps with a trigger:
-- One run of a train with seven stops, numbered 1 to 7, and one coach of four berths.
-- A journey from stop f to stop t holds a berth over the half-open range [f, t): the berth is free again at t.
CREATE TABLE berth (
coach_code TEXT NOT NULL,
berth_no INTEGER NOT NULL,
PRIMARY KEY (coach_code, berth_no)
);
INSERT INTO berth VALUES ('B1', 21), ('B1', 22), ('B1', 23), ('B1', 24);
CREATE TABLE allocation (
coach_code TEXT NOT NULL,
berth_no INTEGER NOT NULL,
passenger TEXT NOT NULL,
from_stop INTEGER NOT NULL,
to_stop INTEGER NOT NULL,
CHECK (from_stop < to_stop),
FOREIGN KEY (coach_code, berth_no) REFERENCES berth (coach_code, berth_no)
);
-- Two journeys on one berth clash when their ranges overlap: each starts before the other ends.
CREATE TRIGGER allocation_no_overlap BEFORE INSERT ON allocation
WHEN EXISTS (
SELECT 1 FROM allocation AS a
WHERE a.coach_code = NEW.coach_code AND a.berth_no = NEW.berth_no
AND a.from_stop < NEW.to_stop AND NEW.from_stop < a.to_stop
)
BEGIN
SELECT RAISE(ABORT, 'berth already sold for part of this journey');
END;
INSERT INTO allocation VALUES ('B1', 23, 'Anu', 1, 4); -- stops 1 to 4
INSERT INTO allocation VALUES ('B1', 23, 'Ravi', 4, 7); -- boards at stop 4, where Anu leaves: no clash
INSERT INTO allocation VALUES ('B1', 23, 'Sara', 3, 5); -- overlaps both: refused
INSERT INTO allocation VALUES ('B1', 22, 'Sara', 3, 5); -- another berth: accepted
INSERT INTO allocation VALUES ('B1', 21, 'Tom', 2, 6);
SELECT berth_no, passenger, from_stop, to_stop FROM allocation ORDER BY berth_no, from_stop;
-- Free berths for a journey from stop 5 to stop 7: no allocation on the berth may overlap [5, 7).
SELECT b.coach_code, b.berth_no
FROM berth AS b
WHERE NOT EXISTS (
SELECT 1 FROM allocation AS a
WHERE a.coach_code = b.coach_code AND a.berth_no = b.berth_no
AND a.from_stop < 7 AND 5 < a.to_stop
)
ORDER BY b.berth_no; Output (exit status 1)
┌──────────┬───────────┬───────────┬─────────┐ │ berth_no │ passenger │ from_stop │ to_stop │ ├──────────┼───────────┼───────────┼─────────┤ │ 21 │ Tom │ 2 │ 6 │ │ 22 │ Sara │ 3 │ 5 │ │ 23 │ Anu │ 1 │ 4 │ │ 23 │ Ravi │ 4 │ 7 │ └──────────┴───────────┴───────────┴─────────┘ ┌────────────┬──────────┐ │ coach_code │ berth_no │ ├────────────┼──────────┤ │ B1 │ 22 │ │ B1 │ 24 │ └────────────┴──────────┘
Printed as an error (standard error)
Runtime error near line 32: berth already sold for part of this journey
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < berths_over_stops.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The trigger refused Sara on berth 23, she got berth 22 instead, and the last query found the berths free from stop 5
to stop 7: 22, which Sara leaves at stop 5, and 24, which nobody holds. A free-berths query is easy to get subtly
wrong, so the next program checks it against a brute force. It books 40 random journeys first-fit on a run of 10 stops
and 16 berths, then compares the query with a segment-by-segment count for all 45 pairs of stops, once with < and
once with <=:
"""Checks the free-berths query of the lesson against a brute-force count, on one run with random bookings.
The run has 10 stops and two coaches of 8 berths. 40 random journey requests are booked first-fit: each gets the
first berth that is free over its whole journey, or is refused. Then, for every pair of stops, the SQL query counts the
free berths, and a brute-force count checks each berth segment by segment. The same is done for the query written with
closed ranges (<= instead of <), a common mistake. Seeded, so every run prints the same.
"""
import random
import sqlite3
STOPS = 10
BERTHS = [(coach, n) for coach in ("B1", "B2") for n in range(1, 9)]
random.seed(11)
db = sqlite3.connect(":memory:")
db.executescript(
"""
CREATE TABLE berth (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL, PRIMARY KEY (coach_code, berth_no));
CREATE TABLE allocation (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL,
from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL, CHECK (from_stop < to_stop));
"""
)
db.executemany("INSERT INTO berth VALUES (?, ?)", BERTHS)
# Brute force: for every berth, which segments (stop s to stop s + 1) are sold.
sold = {berth: [False] * (STOPS + 1) for berth in BERTHS}
booked = refused = 0
for _ in range(40):
f = random.randint(1, STOPS - 1)
t = random.randint(f + 1, STOPS)
for berth in BERTHS:
if not any(sold[berth][s] for s in range(f, t)):
for s in range(f, t):
sold[berth][s] = True
db.execute("INSERT INTO allocation VALUES (?, ?, ?, ?)", (*berth, f, t))
booked += 1
break
else:
refused += 1
FREE = """
SELECT count(*) FROM berth AS b
WHERE NOT EXISTS (SELECT 1 FROM allocation AS a
WHERE a.coach_code = b.coach_code AND a.berth_no = b.berth_no
AND a.from_stop {op} :t AND :f {op} a.to_stop)
"""
pairs = [(f, t) for f in range(1, STOPS) for t in range(f + 1, STOPS + 1)]
brute = {(f, t): sum(not any(sold[b][s] for s in range(f, t)) for b in BERTHS) for f, t in pairs}
print(f"{booked} journeys booked, {refused} refused for lack of a berth")
for name, op in (("half-open [f, t), with <", "<"), ("closed [f, t], with <=", "<=")):
query = FREE.format(op=op)
counts = {(f, t): db.execute(query, {"f": f, "t": t}).fetchone()[0] for f, t in pairs}
wrong = [pair for pair in pairs if counts[pair] != brute[pair]]
print(f"{name}: {len(pairs) - len(wrong)} of {len(pairs)} pairs of stops agree with the brute force")
if wrong:
f, t = wrong[0]
found, real = counts[(f, t)], brute[(f, t)]
print(f" for example stop {f} to stop {t}: the query finds {found} free berths, the brute force {real}")
print("free berths from stop 1:", ", ".join(f"to {t}: {brute[(1, t)]}" for t in range(2, STOPS + 1))) Output
36 journeys booked, 4 refused for lack of a berth half-open [f, t), with <: 45 of 45 pairs of stops agree with the brute force closed [f, t], with <=: 33 of 45 pairs of stops agree with the brute force for example stop 1 to stop 2: the query finds 5 free berths, the brute force 9 free berths from stop 1: to 2: 9, to 3: 5, to 4: 3, to 5: 0, to 6: 0, to 7: 0, to 8: 0, to 9: 0, to 10: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 free_berths_check.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The half-open query agreed on all 45 pairs. Written with <=, which treats both ends as part of the journey, it
disagreed on 12 of them: for a journey from stop 1 to stop 2 it found 5 free berths where 9 were free, because it
counted berths whose journeys merely start at stop 2 as busy.
Letting PostgreSQL refuse overlaps
PostgreSQL can state the rule instead of programming it. Its int4range type stores a range of integers, normalised
to the half-open form [), and && tests whether two ranges overlap. An exclusion constraint then says that no
two rows may have equal run, coach and berth and overlapping journeys. Its index is a GiST index, and the btree_gist
extension lets GiST compare the plain columns with =. PostgreSQL 18 can also say it as a temporal primary key, PRIMARY KEY (run_id, coach_code, berth_no, journey WITHOUT OVERLAPS), which its documentation describes as enforced with an
exclusion constraint:
postgres/berth_ranges.sql
-- The same berths on PostgreSQL 18.6, with the no-overlap rule declared instead of programmed.
SHOW server_version;
-- btree_gist lets a GiST index compare plain columns, such as text and integers, with =.
CREATE EXTENSION btree_gist;
CREATE TABLE berth (
run_id integer NOT NULL,
coach_code text NOT NULL,
berth_no integer NOT NULL,
PRIMARY KEY (run_id, coach_code, berth_no)
);
CREATE TABLE allocation (
run_id integer NOT NULL,
coach_code text NOT NULL,
berth_no integer NOT NULL,
passenger text NOT NULL,
journey int4range NOT NULL CHECK (NOT isempty(journey)),
FOREIGN KEY (run_id, coach_code, berth_no) REFERENCES berth (run_id, coach_code, berth_no),
EXCLUDE USING gist (run_id WITH =, coach_code WITH =, berth_no WITH =, journey WITH &&)
);
INSERT INTO berth SELECT 1, 'B1', n FROM generate_series(21, 24) AS n;
INSERT INTO allocation VALUES (1, 'B1', 23, 'Anu', int4range(1, 4));
-- int4range(4, 7) is [4,7): it starts where Anu's [1,4) ends, so the two do not overlap.
INSERT INTO allocation VALUES (1, 'B1', 23, 'Ravi', int4range(4, 7));
-- expect: error (overlaps both journeys on berth 23)
INSERT INTO allocation VALUES (1, 'B1', 23, 'Sara', int4range(3, 5));
INSERT INTO allocation VALUES (1, 'B1', 22, 'Sara', int4range(3, 5)), (1, 'B1', 21, 'Tom', int4range(2, 6));
-- Free berths from stop 5 to stop 7.
SELECT b.coach_code, b.berth_no
FROM berth AS b
WHERE b.run_id = 1
AND NOT EXISTS (SELECT 1 FROM allocation AS a
WHERE (a.run_id, a.coach_code, a.berth_no) = (b.run_id, b.coach_code, b.berth_no)
AND a.journey && int4range(5, 7))
ORDER BY b.berth_no;
-- PostgreSQL 18 can state the same rule as a temporal primary key: its last column is checked for overlaps.
CREATE TABLE allocation_pk (
run_id integer NOT NULL,
coach_code text NOT NULL,
berth_no integer NOT NULL,
journey int4range NOT NULL,
passenger text NOT NULL,
PRIMARY KEY (run_id, coach_code, berth_no, journey WITHOUT OVERLAPS)
);
INSERT INTO allocation_pk VALUES (1, 'B1', 23, int4range(1, 4), 'Anu'), (1, 'B1', 23, int4range(4, 7), 'Ravi');
-- expect: error
INSERT INTO allocation_pk VALUES (1, 'B1', 23, int4range(3, 5), 'Sara'); postgres/pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples: run SQL files statement by statement and print each statement
with what it returned, or keep several connections open at once to show what concurrent transactions do.
The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file. It is
created in a new folder under the run's temporary folder (TMPDIR), listens only on a Unix socket in that folder (no
network port), and is stopped and removed afterwards. learn:verify's run sandbox cannot read the home folder, where
mise keeps PostgreSQL, so these sessions are recorded with LEARN_VERIFY_NETWORK=allow; they read only this folder and
the toolchain, and they open no network connection.
Statements that create or change tables, and long INSERTs, are printed as their first line only (the SQL files on the
page show them in full). A statement whose comment says "expect: error" must fail, and every other statement must
succeed; otherwise the session exits with status 1, so a run that went wrong is never recorded as if it had worked.
"""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
def _statements(sql):
"""Splits a SQL file into statements: each ends with a semicolon at the end of a line. Comment lines stay with
the statement that follows them."""
out, lines = [], []
for line in sql.splitlines():
if not line.strip() and not lines:
continue
lines.append(line)
if line.rstrip().endswith(";") and not line.lstrip().startswith("--"):
out.append("\n".join(lines).strip())
lines = []
if any(not line.lstrip().startswith("--") and line.strip() for line in lines):
sys.exit("the file ends with a statement that has no semicolon")
return out
def _shown(statement):
"""The lines of a statement as the session prints them: a statement that creates or changes a table, or a long
INSERT, by its first line only (the SQL file on the page shows it in full)."""
lines = statement.splitlines()
code = [line for line in lines if not line.lstrip().startswith("--")]
if len(code) > 1 and code[0].split()[0].upper() in ("CREATE", "ALTER"):
return [code[0].rstrip() + " …"]
if len(code) > 1 and code[0].split()[0].upper() == "INSERT":
return [line for line in lines if line.lstrip().startswith("--")] + [code[0].rstrip() + " …"]
return lines
def _run(args, **kw):
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kw)
def run_file(name):
"""Runs one SQL file on a server of its own."""
with Server() as pg:
pg.play(name)
class Server:
"""A throwaway PostgreSQL 18.6 server for sessions that need several open connections at once."""
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg"))
if len(str(self.base / ".s.PGSQL.5432")) > 103:
sys.exit(f"the socket path in {self.base} is longer than Unix allows (103 bytes): use a shorter TMPDIR")
self.data = self.base / "data"
self.env = {**os.environ, "PGHOST": str(self.base), "PGUSER": "learner", "PGDATABASE": "postgres"}
done = _run([BIN / "initdb", "-D", self.data, "-U", "learner", "--auth=trust", "--no-instructions", "--no-sync",
"-E", "UTF8", "--locale=C"], env=self.env)
if done.returncode != 0:
sys.exit(f"initdb failed:\n{done.stdout}")
options = f"-c listen_addresses='' -c unix_socket_directories='{self.base}'"
options += " -c fsync=off -c full_page_writes=off" # a throwaway server: no need to survive a power cut
log = self.base / "server.log"
done = _run([BIN / "pg_ctl", "-D", self.data, "-o", options, "-l", log, "-w", "start"], env=self.env)
if done.returncode != 0:
sys.exit(f"the server did not start:\n{(self.base / 'server.log').read_text()}")
self.connections = []
return self
def __exit__(self, *exc):
for c in self.connections:
c.close()
_run([BIN / "pg_ctl", "-D", self.data, "-m", "immediate", "-w", "stop"], env=self.env)
shutil.rmtree(self.base, ignore_errors=True)
def play(self, name, quiet=False):
"""Runs a SQL file statement by statement and prints each with its result; quiet prints only a summary line
(and any statement that did not do what its comment expects)."""
failed, count = False, 0
for statement in _statements(Path(name).read_text()):
expect_error = "expect: error" in statement
done = _run([BIN / "psql", "-X", "-v", "VERBOSITY=default", "-c", statement], env=self.env)
count += 1
unexpected = (done.returncode != 0) != expect_error
if not quiet or unexpected:
print("\n".join(("=> " if i == 0 else " ") + line for i, line in enumerate(_shown(statement))))
print(done.stdout.rstrip("\n"))
print()
if unexpected:
failed = True
print(f"[the statement above {'succeeded' if expect_error else 'failed'}, which was not expected]")
if quiet and not failed:
print(f"-- {name}: {count} statements, each as expected")
print()
if failed:
sys.exit(1)
def sql(self, statement):
"""Runs one statement on a connection of its own and returns what psql printed."""
return _run([BIN / "psql", "-X", "-c", statement], env=self.env).stdout.rstrip("\n")
def connect(self, name):
c = Connection(name, self.env)
self.connections.append(c)
return c
class Connection:
"""An open psql session: one connection, so a transaction stays open between statements."""
MARK = "-- statement done --"
def __init__(self, name, env):
self.name = name
self.p = subprocess.Popen([BIN / "psql", "-X", "-v", "ON_ERROR_STOP=0"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
env={**env, "PGAPPNAME": name})
def send(self, statement):
"""Sends a statement without waiting for its result."""
self.p.stdin.write(f"{statement}\n\\echo '{self.MARK}'\n")
self.p.stdin.flush()
def result(self):
"""Waits for the result of the statement sent last."""
lines = []
for line in self.p.stdout:
if line.rstrip("\n") == self.MARK:
break
lines.append(line.rstrip("\n"))
return "\n".join(lines)
def run(self, statement):
self.send(statement)
return self.result()
def close(self):
if self.p.poll() is None:
self.p.stdin.close()
self.p.wait(timeout=10) """Runs berth_ranges.sql on a throwaway PostgreSQL 18.6 server and prints every statement with its result."""
from pg_session import run_file
run_file("berth_ranges.sql") Output
=> -- The same berths on PostgreSQL 18.6, with the no-overlap rule declared instead of programmed.
SHOW server_version;
server_version
----------------
18.6
(1 row)
=> -- btree_gist lets a GiST index compare plain columns, such as text and integers, with =.
CREATE EXTENSION btree_gist;
CREATE EXTENSION
=> CREATE TABLE berth ( …
CREATE TABLE
=> CREATE TABLE allocation ( …
CREATE TABLE
=> INSERT INTO berth SELECT 1, 'B1', n FROM generate_series(21, 24) AS n;
INSERT 0 4
=> INSERT INTO allocation VALUES (1, 'B1', 23, 'Anu', int4range(1, 4));
INSERT 0 1
=> -- int4range(4, 7) is [4,7): it starts where Anu's [1,4) ends, so the two do not overlap.
INSERT INTO allocation VALUES (1, 'B1', 23, 'Ravi', int4range(4, 7));
INSERT 0 1
=> -- expect: error (overlaps both journeys on berth 23)
INSERT INTO allocation VALUES (1, 'B1', 23, 'Sara', int4range(3, 5));
ERROR: conflicting key value violates exclusion constraint "allocation_run_id_coach_code_berth_no_journey_excl"
DETAIL: Key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [3,5)) conflicts with existing key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [1,4)).
=> INSERT INTO allocation VALUES (1, 'B1', 22, 'Sara', int4range(3, 5)), (1, 'B1', 21, 'Tom', int4range(2, 6));
INSERT 0 2
=> -- Free berths from stop 5 to stop 7.
SELECT b.coach_code, b.berth_no
FROM berth AS b
WHERE b.run_id = 1
AND NOT EXISTS (SELECT 1 FROM allocation AS a
WHERE (a.run_id, a.coach_code, a.berth_no) = (b.run_id, b.coach_code, b.berth_no)
AND a.journey && int4range(5, 7))
ORDER BY b.berth_no;
coach_code | berth_no
------------+----------
B1 | 22
B1 | 24
(2 rows)
=> CREATE TABLE allocation_pk ( …
CREATE TABLE
=> INSERT INTO allocation_pk VALUES (1, 'B1', 23, int4range(1, 4), 'Anu'), (1, 'B1', 23, int4range(4, 7), 'Ravi');
INSERT 0 2
=> -- expect: error
INSERT INTO allocation_pk VALUES (1, 'B1', 23, int4range(3, 5), 'Sara');
ERROR: conflicting key value violates exclusion constraint "allocation_pk_pkey"
DETAIL: Key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [3,5)) conflicts with existing key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [1,4)).
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 berth_ranges_session.py
Both forms refused Sara’s [3,5) on berth 23 and named the existing journey it conflicts with. The free-berths query
became one range test, a.journey && int4range(5, 7), and gave the same answer as SQLite.
Why a trigger is not enough
The SQLite trigger is safe in SQLite, because SQLite allows only one writer at a time: two bookings can never check the berth at the same moment. PostgreSQL runs writers concurrently, and under its default isolation level, Read Committed, a query sees only data committed before it began. A trigger that checks for overlaps therefore cannot see a booking that another transaction has made but not yet committed. The next run opens two connections, A and B, and books berth 23 in both before either commits, first with such a trigger and then with the exclusion constraint:
"""Two passengers book berth 23 at the same moment, in two transactions that overlap in time.
First with a trigger that looks for an overlapping journey before each insert, then with the exclusion constraint of
berth_ranges.sql. Each step is printed as "A=>" or "B=>" with what that connection's psql printed.
"""
import sys
import time
from pg_session import Server
TRIGGER = """
CREATE TABLE allocation_trg (berth_no integer NOT NULL, passenger text NOT NULL, from_stop integer NOT NULL,
to_stop integer NOT NULL, CHECK (from_stop < to_stop));
CREATE FUNCTION refuse_overlap() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (SELECT 1 FROM allocation_trg AS a
WHERE a.berth_no = NEW.berth_no AND a.from_stop < NEW.to_stop AND NEW.from_stop < a.to_stop) THEN
RAISE EXCEPTION 'berth % already sold for part of this journey', NEW.berth_no;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER allocation_trg_overlap BEFORE INSERT ON allocation_trg FOR EACH ROW EXECUTE FUNCTION refuse_overlap();
"""
EXCLUSION = """
CREATE EXTENSION btree_gist;
CREATE TABLE allocation_excl (berth_no integer NOT NULL, passenger text NOT NULL, journey int4range NOT NULL,
EXCLUDE USING gist (berth_no WITH =, journey WITH &&));
"""
def step(conn, statement):
print(f"{conn.name}=> {statement}")
out = conn.run(statement)
if out:
print(out)
def waiting(pg, name):
"""What the server says connection `name` is doing: polled until it waits for a lock, for at most 10 s."""
query = f"SELECT state, wait_event_type, wait_event FROM pg_stat_activity WHERE application_name = '{name}'"
for _ in range(200):
out = pg.sql(query)
if "Lock" in out:
return out
time.sleep(0.05)
sys.exit(f"{name} never waited for a lock")
with Server() as pg:
print("-- 1. A trigger checks for overlaps before each insert.")
pg.sql(TRIGGER)
a, b = pg.connect("A"), pg.connect("B")
step(a, "BEGIN;")
step(a, "INSERT INTO allocation_trg VALUES (23, 'Anu', 1, 4);")
step(b, "BEGIN;")
step(b, "INSERT INTO allocation_trg VALUES (23, 'Sara', 3, 5);")
step(a, "COMMIT;")
step(b, "COMMIT;")
print(pg.sql("SELECT berth_no, passenger, from_stop, to_stop FROM allocation_trg ORDER BY from_stop;"))
print("-- B's trigger could not see A's uncommitted row: berth 23 is now sold twice between stops 3 and 4.")
print()
print("-- 2. The exclusion constraint checks for overlaps.")
pg.sql(EXCLUSION)
step(a, "BEGIN;")
step(a, "INSERT INTO allocation_excl VALUES (23, 'Anu', int4range(1, 4));")
step(b, "BEGIN;")
print("B=> INSERT INTO allocation_excl VALUES (23, 'Sara', int4range(3, 5));")
b.send("INSERT INTO allocation_excl VALUES (23, 'Sara', int4range(3, 5));")
print("-- B has no answer yet. The server's view of B:")
print(waiting(pg, "B"))
step(a, "COMMIT;")
print("-- B's insert now ends:")
print(b.result())
step(b, "ROLLBACK;")
print(pg.sql("SELECT berth_no, passenger, journey FROM allocation_excl;")) Output
-- 1. A trigger checks for overlaps before each insert.
A=> BEGIN;
BEGIN
A=> INSERT INTO allocation_trg VALUES (23, 'Anu', 1, 4);
INSERT 0 1
B=> BEGIN;
BEGIN
B=> INSERT INTO allocation_trg VALUES (23, 'Sara', 3, 5);
INSERT 0 1
A=> COMMIT;
COMMIT
B=> COMMIT;
COMMIT
berth_no | passenger | from_stop | to_stop
----------+-----------+-----------+---------
23 | Anu | 1 | 4
23 | Sara | 3 | 5
(2 rows)
-- B's trigger could not see A's uncommitted row: berth 23 is now sold twice between stops 3 and 4.
-- 2. The exclusion constraint checks for overlaps.
A=> BEGIN;
BEGIN
A=> INSERT INTO allocation_excl VALUES (23, 'Anu', int4range(1, 4));
INSERT 0 1
B=> BEGIN;
BEGIN
B=> INSERT INTO allocation_excl VALUES (23, 'Sara', int4range(3, 5));
-- B has no answer yet. The server's view of B:
state | wait_event_type | wait_event
--------+-----------------+---------------
active | Lock | transactionid
(1 row)
A=> COMMIT;
COMMIT
-- B's insert now ends:
ERROR: conflicting key value violates exclusion constraint "allocation_excl_berth_no_journey_excl"
DETAIL: Key (berth_no, journey)=(23, [3,5)) conflicts with existing key (berth_no, journey)=(23, [1,4)).
B=> ROLLBACK;
ROLLBACK
berth_no | passenger | journey
----------+-----------+---------
23 | Anu | [1,4)
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 double_booking_race.py
With the trigger, both transactions committed and berth 23 was sold twice between stops 3 and 4. With the exclusion constraint, B’s insert did not answer at once: the server reported it waiting for a lock on A’s transaction, and as soon as A committed, B’s insert failed with the conflict. The constraint checks against rows that are not yet committed, which no trigger of B can see. A booking service built this way catches the failure and offers the next free berth, or puts the passenger on the waitlist.
Waitlist, cancellation and history
A passenger moves through a small set of states, and each move is a fact worth keeping:
The states of a passenger
Text description of the diagram
Four states joined by arrows.
- A request becomes confirmed (the passenger holds a berth) when a berth is free for the whole journey.
- A request becomes waitlisted (the passenger holds a position) when no berth is free.
- A waitlisted passenger becomes confirmed when a freed berth fits their journey.
- A confirmed passenger and a waitlisted passenger can both cancel, which ends in the cancelled state.
Every arrow is recorded as a new row in passenger_event; no row is ever changed.
Each arrow appends a row to passenger_event; nothing is updated in place. The current state of a passenger is their
latest event, and the full history stays queryable:
-- Booking history as appended rows: nothing is overwritten, and a passenger's current state is their latest event.
CREATE TABLE booking (
booking_id INTEGER PRIMARY KEY, -- internal, never shown
public_ref TEXT NOT NULL UNIQUE, -- what the passenger sees: random, so it reveals no volumes
from_stop INTEGER NOT NULL,
to_stop INTEGER NOT NULL,
CHECK (from_stop < to_stop)
);
CREATE TABLE passenger ( -- a weak entity of BOOKING
booking_id INTEGER NOT NULL REFERENCES booking (booking_id),
passenger_no INTEGER NOT NULL CHECK (passenger_no BETWEEN 1 AND 6),
name TEXT NOT NULL,
PRIMARY KEY (booking_id, passenger_no)
);
CREATE TABLE passenger_event (
event_id INTEGER PRIMARY KEY, -- the order in which things happened
booking_id INTEGER NOT NULL,
passenger_no INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('confirmed', 'waitlisted', 'cancelled')),
coach_code TEXT, -- the berth, for a confirmed passenger
berth_no INTEGER,
FOREIGN KEY (booking_id, passenger_no) REFERENCES passenger (booking_id, passenger_no),
CHECK ((status = 'confirmed') = (berth_no IS NOT NULL))
);
INSERT INTO booking VALUES (1, 'K7Q2M9XW', 1, 4), (2, 'P3ZT8HNA', 2, 4);
INSERT INTO passenger VALUES (1, 1, 'Anu'), (1, 2, 'Ravi'), (2, 1, 'Sara');
INSERT INTO passenger_event (booking_id, passenger_no, status, coach_code, berth_no) VALUES
(1, 1, 'confirmed', 'B1', 23),
(1, 2, 'confirmed', 'B1', 24),
(2, 1, 'waitlisted', NULL, NULL),
(1, 2, 'cancelled', NULL, NULL), -- Ravi cancels, which frees B1-24
(2, 1, 'confirmed', 'B1', 24); -- Sara, first on the waitlist, takes it
-- Each passenger's current state: the event with the highest event_id.
SELECT b.public_ref, p.name, e.status, e.coach_code, e.berth_no
FROM passenger_event AS e
JOIN passenger AS p ON p.booking_id = e.booking_id AND p.passenger_no = e.passenger_no
JOIN booking AS b ON b.booking_id = e.booking_id
WHERE e.event_id = (SELECT max(x.event_id) FROM passenger_event AS x
WHERE x.booking_id = e.booking_id AND x.passenger_no = e.passenger_no)
ORDER BY b.public_ref, p.passenger_no;
-- And everything that ever happened to Sara's booking, which an overwritten status would have lost.
SELECT event_id, status, coach_code, berth_no
FROM passenger_event
WHERE booking_id = 2
ORDER BY event_id; Output
┌────────────┬──────┬───────────┬────────────┬──────────┐ │ public_ref │ name │ status │ coach_code │ berth_no │ ├────────────┼──────┼───────────┼────────────┼──────────┤ │ K7Q2M9XW │ Anu │ confirmed │ B1 │ 23 │ │ K7Q2M9XW │ Ravi │ cancelled │ │ │ │ P3ZT8HNA │ Sara │ confirmed │ B1 │ 24 │ └────────────┴──────┴───────────┴────────────┴──────────┘ ┌──────────┬────────────┬────────────┬──────────┐ │ event_id │ status │ coach_code │ berth_no │ ├──────────┼────────────┼────────────┼──────────┤ │ 3 │ waitlisted │ │ │ │ 5 │ confirmed │ B1 │ 24 │ └──────────┴────────────┴────────────┴──────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < booking_history.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Ravi’s cancellation did not erase his confirmation: both events are there, and so is Sara’s time on the waitlist. The allocation table, by contrast, holds only what is true now, because the no-overlap rule must apply to current journeys only. Cancelling therefore deletes one allocation and appends one event, and promoting a waitlisted passenger inserts an allocation, removes the waitlist entry and appends another event, all in one transaction. The exercise below asks you to write the query that picks the passenger to promote.
The final schema, tested with bookings
The core tables of the reservation design
Text description of the diagram
Six tables with their columns and key markers (PK for primary key, FK for foreign key, UNQ for unique).
1. allocation: booking_id and passenger_no (both part of the primary key and a foreign key to passenger), run_id, coach_code and berth_no (a foreign key to berth), and journey, an int4range. A line to passenger shows zero or one allocation per passenger; a line to berth shows zero or many allocations per berth. 2. passenger: booking_id (primary key and foreign key) and passenger_no (primary key), and name. A line to booking shows one or many passengers per booking. 3. berth: run_id, coach_code and berth_no, all part of the primary key. A line to coach shows one or many berths per coach. 4. booking: booking_id (primary key), public_ref (unique), run_id (foreign key) and journey. A line to run shows zero or many bookings per run. 5. coach: run_id and coach_code (primary key) and class. A line to run shows one or many coaches per run. 6. run: run_id (primary key), train_no (foreign key) and run_date.
Two details of the final tables carry the rules. The allocation repeats its booking’s run and journey, because an
exclusion constraint compares columns of one table only; a foreign key to booking (booking_id, run_id, journey)
makes a copy that differs from the booking impossible. And the waitlist has a primary key per run, class and
position, with UNIQUE on the passenger, so nobody waits twice. The schema file runs quietly first; then a day of
bookings is replayed against it:
postgres/reservation_schema.sql
-- The final design on PostgreSQL 18.6: the tables, one train, one run and one coach of four berths.
CREATE EXTENSION btree_gist;
CREATE TABLE station (
station_code text PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE train (
train_no text PRIMARY KEY,
name text NOT NULL
);
-- STOP is a weak entity of TRAIN: the train's key plus the stop's place on the route.
CREATE TABLE stop (
train_no text NOT NULL REFERENCES train (train_no),
seq integer NOT NULL CHECK (seq > 0),
station_code text NOT NULL REFERENCES station (station_code),
PRIMARY KEY (train_no, seq)
);
-- A run is one train on one date: a surrogate key keeps the keys below short, UNIQUE keeps the natural key.
CREATE TABLE run (
run_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
train_no text NOT NULL REFERENCES train (train_no),
run_date date NOT NULL,
UNIQUE (train_no, run_date)
);
CREATE TABLE coach (
run_id integer NOT NULL REFERENCES run (run_id),
coach_code text NOT NULL,
class text NOT NULL CHECK (class IN ('sleeper', 'ac3', 'ac2')),
PRIMARY KEY (run_id, coach_code)
);
CREATE TABLE berth (
run_id integer NOT NULL,
coach_code text NOT NULL,
berth_no integer NOT NULL CHECK (berth_no > 0),
PRIMARY KEY (run_id, coach_code, berth_no),
FOREIGN KEY (run_id, coach_code) REFERENCES coach (run_id, coach_code)
);
-- A booking: an internal key, a random reference for the passenger, one run and one journey of stops [from, to).
CREATE TABLE booking (
booking_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_ref text NOT NULL UNIQUE CHECK (public_ref ~ '^[A-Z0-9]{8}$'),
run_id integer NOT NULL REFERENCES run (run_id),
journey int4range NOT NULL CHECK (lower(journey) >= 1 AND NOT isempty(journey)),
UNIQUE (booking_id, run_id, journey)
);
CREATE TABLE passenger (
booking_id bigint NOT NULL REFERENCES booking (booking_id),
passenger_no integer NOT NULL CHECK (passenger_no BETWEEN 1 AND 6),
name text NOT NULL,
PRIMARY KEY (booking_id, passenger_no)
);
-- An allocation repeats its booking's run and journey, because an exclusion constraint compares columns of one
-- table; the three-column foreign key keeps the copy equal to the booking.
CREATE TABLE allocation (
booking_id bigint NOT NULL,
passenger_no integer NOT NULL,
run_id integer NOT NULL,
journey int4range NOT NULL,
coach_code text NOT NULL,
berth_no integer NOT NULL,
PRIMARY KEY (booking_id, passenger_no),
FOREIGN KEY (booking_id, passenger_no) REFERENCES passenger (booking_id, passenger_no),
FOREIGN KEY (booking_id, run_id, journey) REFERENCES booking (booking_id, run_id, journey),
FOREIGN KEY (run_id, coach_code, berth_no) REFERENCES berth (run_id, coach_code, berth_no),
EXCLUDE USING gist (run_id WITH =, coach_code WITH =, berth_no WITH =, journey WITH &&)
);
CREATE TABLE waitlist_entry (
run_id integer NOT NULL REFERENCES run (run_id),
class text NOT NULL,
position integer NOT NULL,
booking_id bigint NOT NULL,
passenger_no integer NOT NULL,
PRIMARY KEY (run_id, class, position),
UNIQUE (booking_id, passenger_no),
FOREIGN KEY (booking_id, passenger_no) REFERENCES passenger (booking_id, passenger_no)
);
-- The history: every change of a passenger's state is a new row.
CREATE TABLE passenger_event (
event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
booking_id bigint NOT NULL,
passenger_no integer NOT NULL,
status text NOT NULL CHECK (status IN ('confirmed', 'waitlisted', 'cancelled')),
FOREIGN KEY (booking_id, passenger_no) REFERENCES passenger (booking_id, passenger_no)
);
INSERT INTO station VALUES ('ARN', 'Arunagar'), ('BKP', 'Bakulpur'), ('CDP', 'Chandrapuri'), ('DVN', 'Devnagar'),
('EKB', 'Ekamba'), ('FLP', 'Fulpur'), ('GRN', 'Girinagar');
INSERT INTO train VALUES ('99001', 'Lesson Express');
INSERT INTO stop SELECT '99001', n, (ARRAY['ARN', 'BKP', 'CDP', 'DVN', 'EKB', 'FLP', 'GRN'])[n] FROM generate_series(1, 7) AS n;
INSERT INTO run (train_no, run_date) VALUES ('99001', '2026-12-20');
INSERT INTO coach VALUES (1, 'B1', 'ac3');
INSERT INTO berth SELECT 1, 'B1', n FROM generate_series(21, 24) AS n; postgres/bookings.sql
-- A day of bookings on run 1 of train 99001, coach B1, berths 21 to 24.
INSERT INTO booking (public_ref, run_id, journey) VALUES ('K7Q2M9XW', 1, '[1,4)'), ('P3ZT8HNA', 1, '[4,7)'),
('H8D4RW2E', 1, '[2,5)'), ('U6M2LJ9T', 1, '[1,6)'), ('W5N6C3QY', 1, '[1,3)');
INSERT INTO passenger VALUES (1, 1, 'Anu'), (1, 2, 'Ravi'), (2, 1, 'Sara'), (3, 1, 'Tom'), (4, 1, 'Uma'), (5, 1, 'Dev');
INSERT INTO allocation VALUES (1, 1, 1, '[1,4)', 'B1', 23), (1, 2, 1, '[1,4)', 'B1', 24), (2, 1, 1, '[4,7)', 'B1', 23);
-- expect: error (Tom's journey overlaps Anu's on berth 23)
INSERT INTO allocation VALUES (3, 1, 1, '[2,5)', 'B1', 23);
-- expect: error (an allocation whose journey differs from its booking's)
INSERT INTO allocation VALUES (3, 1, 1, '[2,4)', 'B1', 22);
INSERT INTO allocation VALUES (3, 1, 1, '[2,5)', 'B1', 22), (4, 1, 1, '[1,6)', 'B1', 21);
INSERT INTO passenger_event (booking_id, passenger_no, status)
VALUES (1, 1, 'confirmed'), (1, 2, 'confirmed'), (2, 1, 'confirmed'), (3, 1, 'confirmed'), (4, 1, 'confirmed');
-- Dev asks for stops 1 to 3: no berth of B1 is free over [1,3), so he joins the waitlist.
SELECT count(*) AS free_berths FROM berth AS b
WHERE b.run_id = 1 AND NOT EXISTS (SELECT 1 FROM allocation AS a WHERE a.run_id = b.run_id
AND a.coach_code = b.coach_code AND a.berth_no = b.berth_no AND a.journey && '[1,3)');
INSERT INTO waitlist_entry VALUES (1, 'ac3', 1, 5, 1);
INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (5, 1, 'waitlisted');
-- Ravi cancels: his allocation goes, an event records it, and berth 24 is free over [1,4), which fits Dev.
DELETE FROM allocation WHERE booking_id = 1 AND passenger_no = 2;
INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (1, 2, 'cancelled');
INSERT INTO allocation VALUES (5, 1, 1, '[1,3)', 'B1', 24);
DELETE FROM waitlist_entry WHERE booking_id = 5 AND passenger_no = 1;
INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (5, 1, 'confirmed');
-- Each passenger's current state, the latest event, and their berth.
SELECT DISTINCT ON (p.booking_id, p.passenger_no) b.public_ref, p.name, e.status, a.coach_code, a.berth_no
FROM passenger AS p
JOIN booking AS b ON b.booking_id = p.booking_id
JOIN passenger_event AS e ON e.booking_id = p.booking_id AND e.passenger_no = p.passenger_no
LEFT JOIN allocation AS a ON a.booking_id = p.booking_id AND a.passenger_no = p.passenger_no
ORDER BY p.booking_id, p.passenger_no, e.event_id DESC; """Creates the reservation tables on a throwaway PostgreSQL 18.6 server, then replays a day of sample bookings."""
from pg_session import Server
with Server() as pg:
pg.play("reservation_schema.sql", quiet=True)
pg.play("bookings.sql") Output
-- reservation_schema.sql: 18 statements, each as expected
=> -- A day of bookings on run 1 of train 99001, coach B1, berths 21 to 24.
INSERT INTO booking (public_ref, run_id, journey) VALUES ('K7Q2M9XW', 1, '[1,4)'), ('P3ZT8HNA', 1, '[4,7)'), …
INSERT 0 5
=> INSERT INTO passenger VALUES (1, 1, 'Anu'), (1, 2, 'Ravi'), (2, 1, 'Sara'), (3, 1, 'Tom'), (4, 1, 'Uma'), (5, 1, 'Dev');
INSERT 0 6
=> INSERT INTO allocation VALUES (1, 1, 1, '[1,4)', 'B1', 23), (1, 2, 1, '[1,4)', 'B1', 24), (2, 1, 1, '[4,7)', 'B1', 23);
INSERT 0 3
=> -- expect: error (Tom's journey overlaps Anu's on berth 23)
INSERT INTO allocation VALUES (3, 1, 1, '[2,5)', 'B1', 23);
ERROR: conflicting key value violates exclusion constraint "allocation_run_id_coach_code_berth_no_journey_excl"
DETAIL: Key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [2,5)) conflicts with existing key (run_id, coach_code, berth_no, journey)=(1, B1, 23, [1,4)).
=> -- expect: error (an allocation whose journey differs from its booking's)
INSERT INTO allocation VALUES (3, 1, 1, '[2,4)', 'B1', 22);
ERROR: insert or update on table "allocation" violates foreign key constraint "allocation_booking_id_run_id_journey_fkey"
DETAIL: Key (booking_id, run_id, journey)=(3, 1, [2,4)) is not present in table "booking".
=> INSERT INTO allocation VALUES (3, 1, 1, '[2,5)', 'B1', 22), (4, 1, 1, '[1,6)', 'B1', 21);
INSERT 0 2
=> INSERT INTO passenger_event (booking_id, passenger_no, status) …
INSERT 0 5
=> -- Dev asks for stops 1 to 3: no berth of B1 is free over [1,3), so he joins the waitlist.
SELECT count(*) AS free_berths FROM berth AS b
WHERE b.run_id = 1 AND NOT EXISTS (SELECT 1 FROM allocation AS a WHERE a.run_id = b.run_id
AND a.coach_code = b.coach_code AND a.berth_no = b.berth_no AND a.journey && '[1,3)');
free_berths
-------------
0
(1 row)
=> INSERT INTO waitlist_entry VALUES (1, 'ac3', 1, 5, 1);
INSERT 0 1
=> INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (5, 1, 'waitlisted');
INSERT 0 1
=> -- Ravi cancels: his allocation goes, an event records it, and berth 24 is free over [1,4), which fits Dev.
DELETE FROM allocation WHERE booking_id = 1 AND passenger_no = 2;
DELETE 1
=> INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (1, 2, 'cancelled');
INSERT 0 1
=> INSERT INTO allocation VALUES (5, 1, 1, '[1,3)', 'B1', 24);
INSERT 0 1
=> DELETE FROM waitlist_entry WHERE booking_id = 5 AND passenger_no = 1;
DELETE 1
=> INSERT INTO passenger_event (booking_id, passenger_no, status) VALUES (5, 1, 'confirmed');
INSERT 0 1
=> -- Each passenger's current state, the latest event, and their berth.
SELECT DISTINCT ON (p.booking_id, p.passenger_no) b.public_ref, p.name, e.status, a.coach_code, a.berth_no
FROM passenger AS p
JOIN booking AS b ON b.booking_id = p.booking_id
JOIN passenger_event AS e ON e.booking_id = p.booking_id AND e.passenger_no = p.passenger_no
LEFT JOIN allocation AS a ON a.booking_id = p.booking_id AND a.passenger_no = p.passenger_no
ORDER BY p.booking_id, p.passenger_no, e.event_id DESC;
public_ref | name | status | coach_code | berth_no
------------+------+-----------+------------+----------
K7Q2M9XW | Anu | confirmed | B1 | 23
K7Q2M9XW | Ravi | cancelled | |
P3ZT8HNA | Sara | confirmed | B1 | 23
H8D4RW2E | Tom | confirmed | B1 | 22
U6M2LJ9T | Uma | confirmed | B1 | 21
W5N6C3QY | Dev | confirmed | B1 | 24
(6 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reservation_session.py
The constraints did the checking: Tom’s journey over Anu’s berth was refused by the exclusion constraint, an allocation whose journey disagreed with its booking by the three-column foreign key, and Dev found no free berth over [1,3) and joined the waitlist. When Ravi cancelled, berth 24 became free over [1,4), which fits Dev’s [1,3), and the last query shows everyone’s state from the latest event: Ravi cancelled, Dev confirmed on berth 24.
The case study simplifies on purpose. It leaves out fares, payment, quotas for groups of passengers, waitlists that move between classes, seat preferences, and coaches that join or leave a train during its route. Each of them adds tables and rules, but none of them changes the core: journeys as ranges, enforced where the data lives.
SQL to ER Diagram Paste reservation_schema.sql and compare the drawing with the design of this lesson.Interview questions
Why are journeys stored as half-open stop ranges [from, to)? Because a passenger stops using the berth at the stop
where they get off, and someone else can board there. With half-open ranges a journey [1,4) and a journey [4,7) share
no segment, so the overlap test f1 < t2 AND f2 < t1 treats them as compatible, which is right. Closed ranges [1,4]
and [4,7] would share stop 4 and make the berth look taken. Half-open ranges also join and split cleanly: [1,4) and
[4,7) together cover exactly [1,7), and the number of segments a journey uses is simply t − f. PostgreSQL normalises
integer ranges to this form.
Key takeaways
- Read the brief for entities, then decide each borderline case on purpose: station as an entity, stop as a weak entity, booking as an entity, allocation as a binary relationship with the journey as its attribute.
- A chain of weak entities can be cut with one surrogate key, keeping the natural key as UNIQUE.
- Store a journey as the half-open range [from, to); two journeys clash when each starts before the other ends.
- Check a query that is easy to get subtly wrong against a brute force; closed ranges disagreed on 12 of 45 pairs here.
- An exclusion constraint, or PostgreSQL 18’s WITHOUT OVERLAPS, enforces the rule even between concurrent transactions; a trigger that checks first can let two bookings through.
- Keep history as appended events and current state in the rows the constraints guard; show passengers a random reference, never the internal key.
Exercise
Exercise · Medium · SQL
Choose who gets a freed berth from the waitlist
A cancellation has just freed a berth on one run of a train. Extend the design with the query that decides who gets it. Three tables describe the moment, for one class of one run:
freed (coach_code, berth_no): one row, the berth that was freed;allocation (coach_code, berth_no, from_stop, to_stop): the journeys still sold on every berth;waitlist (position, passenger, from_stop, to_stop): the waitlisted passengers, the lowest position first.
Journeys are half-open ranges of stops, [from_stop, to_stop): a journey that starts at the stop where another ends does not overlap it. The freed berth may still be sold for other parts of the route.
Write a query that returns position, passenger, coach_code and berth_no for the waitlisted passenger who gets the freed berth: the one with the lowest position whose journey overlaps no remaining allocation of that berth. Return no row when nobody's journey fits. The starter promotes the first passenger in line whether or not their journey fits.
When your query passes the tests, check the rest of your design against these questions: is the promotion done in the same transaction as the cancellation? What happens when two berths are freed at once? Does the promoted passenger get a new event row, and does their waitlist entry go?
Starter code · promote_from_waitlist.sql
-- Promotes the first passenger in line, whether or not their journey fits the freed berth.
SELECT w.position, w.passenger, f.coach_code, f.berth_no
FROM waitlist AS w
CROSS JOIN freed AS f
ORDER BY w.position
LIMIT 1; The sample tests · tests.yaml
# Sample tests: each one builds the three tables, runs your query and compares the rows it returns.
tests:
- name: the first passenger in line fits
setup: |
CREATE TABLE freed (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL);
CREATE TABLE allocation (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
CREATE TABLE waitlist (position INTEGER PRIMARY KEY, passenger TEXT NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
INSERT INTO freed VALUES ('B1', 24);
INSERT INTO allocation VALUES ('B1', 23, 1, 7), ('B1', 24, 5, 7);
INSERT INTO waitlist VALUES (1, 'Dev', 1, 3), (2, 'Isha', 2, 4);
expected:
columns: [position, passenger, coach_code, berth_no]
rows:
- [1, Dev, B1, 24]
- name: the first does not fit; the second boards where a sold journey ends
setup: |
CREATE TABLE freed (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL);
CREATE TABLE allocation (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
CREATE TABLE waitlist (position INTEGER PRIMARY KEY, passenger TEXT NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
INSERT INTO freed VALUES ('B1', 22);
INSERT INTO allocation VALUES ('B1', 22, 1, 3), ('B1', 22, 6, 8), ('B1', 21, 3, 6);
INSERT INTO waitlist VALUES (1, 'Mira', 2, 5), (2, 'Kabir', 3, 6), (3, 'Lalit', 3, 5);
expected:
columns: [position, passenger, coach_code, berth_no]
rows:
- [2, Kabir, B1, 22]
- name: nobody fits, and the berth stays free
setup: |
CREATE TABLE freed (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL);
CREATE TABLE allocation (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
CREATE TABLE waitlist (position INTEGER PRIMARY KEY, passenger TEXT NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
INSERT INTO freed VALUES ('S4', 7);
INSERT INTO allocation VALUES ('S4', 7, 2, 4);
INSERT INTO waitlist VALUES (4, 'Nisha', 1, 3), (9, 'Om', 3, 6);
expected:
columns: [position, passenger, coach_code, berth_no]
rows: []
- name: several fit, and the lowest position wins
setup: |
CREATE TABLE freed (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL);
CREATE TABLE allocation (coach_code TEXT NOT NULL, berth_no INTEGER NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
CREATE TABLE waitlist (position INTEGER PRIMARY KEY, passenger TEXT NOT NULL, from_stop INTEGER NOT NULL, to_stop INTEGER NOT NULL);
INSERT INTO freed VALUES ('A1', 5);
INSERT INTO allocation VALUES ('A1', 5, 4, 6);
INSERT INTO waitlist VALUES (3, 'Pooja', 1, 5), (7, 'Qadir', 1, 4), (11, 'Rhea', 6, 9);
expected:
columns: [position, passenger, coach_code, berth_no]
rows:
- [7, Qadir, A1, 5] A hint
Keep only the waitlisted journeys that fit: for each one, NOT EXISTS an allocation on the freed berth that overlaps it. Two half-open ranges [a1, a2) and [b1, b2) overlap exactly when a1 < b2 AND b1 < a2. The ORDER BY and LIMIT 1 of the starter then pick the right passenger from what is left.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- PostgreSQL 18 documentation: 5.5 Constraints (exclusion constraints) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 8.17 Range Types (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: btree_gist (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE TABLE (WITHOUT OVERLAPS) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 13.2 Transaction Isolation (The PostgreSQL Global Development Group)
- CREATE TRIGGER (SQLite)
- Appropriate Uses For SQLite (SQLite)
- sqlite3: DB-API 2.0 interface for SQLite databases (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress