Liam O'Brien supervises bookings for a small regional railway, six stations and four trains, and keeps every booking as one row in a spreadsheet with the passengers in one cell and their seats in another, "S1-1, S1-2, S1-3". Twice last month the railway sold the same seat to two people. This post designs the reservation database from a conversation with him: eleven tables on SQL Server and PostgreSQL, a route that is an ordered list of stops with times and distances, fares copied onto the ticket so that next year's prices do not rewrite this year's, a seat that cannot be sold twice for the same date, a waiting list that moves up when someone cancels, and the spreadsheet shown accepting the double sale without a word.
- Download the script for your engine: p08-sqlserver.sql or p08-postgresql.sql
- Run it:
sqlcmd -S "(localdb)\MSSQLLocalDB" -I -f 65001 -i p08-sqlserver.sql, orpsql -d postgres -c "CREATE DATABASE sp_p08"thenpsql -d sp_p08 -e -f p08-postgresql.sql - Find the
WHERE status = 'confirmed'on the unique index in section 2 - Read section 3: the double assignment refused, the cancellation that frees two seats, and the waiting list moving up
- Read 3e: a plain unique constraint on the same columns fails, and the two engines name different duplicates
- Read section 5: the spreadsheet's
LIKE '%S1-1%'finds seat S1-11 too, and sells F1-1 twice in silence - Paste sections 1 and 2 into the ER diagram tool and p08-sheet.csv into the normalization checker
The conversation
Liam: Each train has a number and a name and runs a fixed route. The 101, the Bayview Express, leaves Northgate at 06:00, stops at Riverside Junction, Millbrook and Eastport, and reaches Bayview at 08:15. The 102 does the same trip back in the evening. The 301 runs the same stations at other times, and the 201 turns off at Riverside Junction to Hillcrest.
Designer: So the times belong to the train, not to the line. Each stop is one row: train, position in the order, station, arrival, departure. Does every train run every day?
Liam: No. The 101 and 102 daily, the 201 Monday to Saturday, the 301 only Monday, Wednesday and Friday. Passengers sit in coaches: F1 is first class, S1 and S2 second. A first-class coach has four seats, a second-class twelve, numbered from 1. The ticket names coach and seat, like S1-11.
Designer: A train has a set of running days, a coach belongs to one train and has one class, a seat belongs to one coach. What is on a booking?
Liam: One train, one date, where they get on, where they get off, the class, and the people travelling. Maria Garcia booked herself and David Chen, Northgate to Bayview, first class, on Wednesday 4 November. When someone phones, they give us the six-letter booking code. And people come back: Maria and David go home on the 102 the same evening.
Designer: Then a passenger is written down once, and a separate row links passenger, booking and seat. How do you work out the price?
Liam: By class and distance. Second class is 20 up to 25 km, 35 up to 50, 55 up to 100; first class 45, 75 and 120. And if the fares change next year, old tickets must still show what was paid.
Designer: So every stop also carries its distance from the first station, the fares are a list of bands, and the fare is copied onto the passenger's row at booking time. Now the rule that was broken twice: when is a seat taken?
Liam: Strictly, only between where a passenger boards and where they leave; someone else could use the seat for the rest of the run. But for a railway our size, one seat, one confirmed passenger per train per date is fine. We lose a few short trips, but we never sell a seat twice. When seats run out, passengers go on a waiting list in the order the bookings came in, WL 1, WL 2.
Designer: Then the database can refuse a seat and a date appearing twice among the confirmed passengers, and each passenger row has a status and, while waiting, a position. What happens when someone cancels?
Liam: Aisha Khan cancelled the booking for herself and Tomas Silva. We refunded 180 of the 240 and kept 60. Their two seats went to the first two on the list, and the others moved up two places.
Designer: The cancellation is a row of its own with its time and refund; the passengers' status becomes cancelled, which frees the seats; the first two waiting passengers get them. All in one transaction, so the railway is never half-way through it.
From sentences to tables
| Liam said | Entity | Columns |
|---|---|---|
| six stations, four trains with a number and a name | stations, trains | station_code, station_name; train_no, train_name |
| the list in order, with arrival and departure at each stop; distance | route_stops | train_id, stop_seq, station_id, arrival_time, departure_time, km_from_origin |
| daily, Monday to Saturday, Monday Wednesday Friday | train_days | one row per train per weekday |
| F1 first class, S1 and S2 second; four or twelve seats numbered from 1 | coaches, seats | coach_code, travel_class; seat_no unique inside its coach |
| 20 up to 25 km, 35 up to 50, 55 up to 100 | fares | travel_class, min_km, max_km, amount |
| one train, one date, on, off, class, the people travelling, a six-letter code | bookings, passengers | booking_code, train_id, journey_date, from_station_id, to_station_id, travel_class |
| each person gets a seat; old tickets must still show what was paid; WL 1, WL 2 | booking_passengers | seat_id, status, waitlist_no, fare, and a copy of journey_date |
| we refunded 180 of the 240 | cancellations | booking_id, cancelled_at, refund_amount |
One seat, one confirmed passenger, one date
The passenger row is where the design earns its keep. It copies the journey date from the booking, so that the seat rule can be a rule on one table, and a two-column foreign key keeps the copy honest: a passenger row whose date differs from its booking's date is refused.
CREATE TABLE booking_passengers (
booking_id integer NOT NULL,
passenger_id integer NOT NULL CONSTRAINT fk_booking_passengers_passengers REFERENCES passengers (passenger_id),
journey_date date NOT NULL,
seat_id integer NULL CONSTRAINT fk_booking_passengers_seats REFERENCES seats (seat_id),
status varchar(10) NOT NULL CONSTRAINT ck_booking_passengers_status CHECK (status IN ('confirmed', 'waitlisted', 'cancelled')),
waitlist_no smallint NULL CONSTRAINT ck_booking_passengers_waitlist_no CHECK (waitlist_no >= 1),
fare numeric(8,2) NOT NULL,
CONSTRAINT pk_booking_passengers PRIMARY KEY (booking_id, passenger_id),
CONSTRAINT fk_booking_passengers_bookings FOREIGN KEY (booking_id, journey_date)
REFERENCES bookings (booking_id, journey_date) ON UPDATE CASCADE,
CONSTRAINT ck_booking_passengers_seat CHECK (
(status = 'confirmed' AND seat_id IS NOT NULL AND waitlist_no IS NULL)
OR (status = 'waitlisted' AND seat_id IS NULL AND waitlist_no IS NOT NULL)
OR (status = 'cancelled' AND waitlist_no IS NULL))
);
And the rule itself is a unique index on the seat and the date that only looks at confirmed rows,
the same statement on both engines apart from the dbo. prefix:
CREATE UNIQUE INDEX ux_booking_passengers_seat_date
ON booking_passengers (seat_id, journey_date)
WHERE status = 'confirmed';
Section 3a has the clerk give waitlisted Yuki Tanaka seat F1-3 while Aisha Khan holds it for the same day. PostgreSQL:
ERROR: duplicate key value violates unique constraint "ux_booking_passengers_seat_date"
DETAIL: Key (seat_id, journey_date)=(3, 2026-11-04) already exists.
And SQL Server, with error 2601:
Cannot insert duplicate key row in object 'dbo.booking_passengers' with unique index 'ux_booking_passengers_seat_date'. The duplicate key value is (3, 2026-11-04).
Then Aisha cancels, in one transaction: the cancellation row with the 180 refund, her two rows become cancelled, and the first two waiting passengers take F1-3 and F1-4 while the rest move up two places. The same statement that was refused a moment ago now succeeds, and this is the state afterwards:
booking_code | full_name | status | seat | waitlist_no
--------------+-----------------+------------+------+-------------
K4TR7Q | Maria Garcia | confirmed | F1-1 |
K4TR7Q | David Chen | confirmed | F1-2 |
M8WX2D | Aisha Khan | cancelled | F1-3 |
M8WX2D | Tomas Silva | cancelled | F1-4 |
P2LN6V | Yuki Tanaka | confirmed | F1-3 |
R7HC3J | Fatima Al-Sayed | confirmed | F1-4 |
R7HC3J | Priya Nair | waitlisted | | 1
R7HC3J | Wei Zhang | waitlisted | | 2
(8 rows)
Aisha's cancelled row keeps F1-3 as history and Yuki holds F1-3 as confirmed, which is exactly what the filter allows. Section 3e shows why the filter is not optional: a plain unique constraint on the same two columns fails on the loaded data, and the two engines complain about different rows. PostgreSQL names the cancelled-plus-confirmed pair on seat 3; SQL Server, which treats NULLs as equal in a unique index, names the waitlisted rows with no seat at all:
ERROR: could not create unique index "uq_booking_passengers_seat_date"
DETAIL: Key (seat_id, journey_date)=(3, 2026-11-04) is duplicated.
The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.booking_passengers' and the index name 'uq_booking_passengers_seat_date'. The duplicate key value is (<NULL>, 2026-11-04).
One SQL Server trap on the way: a filtered index needs QUOTED_IDENTIFIER on. The script
sets it itself, and a copy without that line, run without sqlcmd -I, failed to create
the index with error 1934 and then accepted the double sale.
The ER diagram
Five questions the booking office asks
The journey date in every query is Wednesday 4 November 2026. Which trains run from Northgate to Riverside Junction that day, found by two stops of the same train in the right order and a row in the running days:
train_no | train_name | departs | arrives
----------+---------------------+----------+----------
101 | Bayview Express | 06:00:00 | 06:25:00
201 | Hillcrest Passenger | 08:30:00 | 08:55:00
301 | Market Day Fast | 11:00:00 | 11:22:00
(3 rows)
The same pair on the Sunday returns the 101 alone. Seats free on the 101 that day, per coach, with the free seat numbers listed:
coach_code | travel_class | seats | taken | free | free_seats
------------+--------------+-------+-------+------+---------------------------------------
F1 | first | 4 | 4 | 0 |
S1 | second | 12 | 4 | 8 | 4, 5, 6, 7, 8, 9, 10, 12
S2 | second | 12 | 0 | 12 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
(3 rows)
The passenger chart for the guard, and the waiting list under it:
seat | full_name | board | alight | booking_code
-------+-----------------+-------+--------+--------------
F1-1 | Maria Garcia | NGT | BYV | K4TR7Q
F1-2 | David Chen | NGT | BYV | K4TR7Q
F1-3 | Yuki Tanaka | RVJ | EPT | P2LN6V
F1-4 | Fatima Al-Sayed | NGT | MLB | R7HC3J
S1-1 | Elena Rossi | NGT | RVJ | T5BM9K
S1-2 | Kwame Mensah | NGT | RVJ | T5BM9K
S1-3 | Hannah Weber | NGT | RVJ | T5BM9K
S1-11 | Omar Haddad | MLB | BYV | V3QF8N
(8 rows)
travel_class | waitlist_no | full_name | board | alight | booking_code
--------------+-------------+------------+-------+--------+--------------
first | 1 | Priya Nair | NGT | MLB | R7HC3J
first | 2 | Wei Zhang | NGT | MLB | R7HC3J
(2 rows)
A booking looked up by the code a caller reads out, every passenger with their status and the fare charged:
booking_code | train_no | journey_date | board | alight | travel_class | full_name | status | seat | fare
--------------+----------+--------------+-------+--------+--------------+-----------------+------------+------+-------
R7HC3J | 101 | 2026-11-04 | NGT | MLB | first | Fatima Al-Sayed | confirmed | F1-4 | 75.00
R7HC3J | 101 | 2026-11-04 | NGT | MLB | first | Priya Nair | waitlisted | WL 1 | 75.00
R7HC3J | 101 | 2026-11-04 | NGT | MLB | first | Wei Zhang | waitlisted | WL 2 | 75.00
(3 rows)
And revenue per train for November, confirmed fares plus what the railway kept from cancellations, waitlisted fares excluded:
train_no | train_name | ticket_fares | cancellation_charges | revenue
----------+---------------------+--------------+----------------------+---------
101 | Bayview Express | 485.00 | 60.00 | 545.00
102 | Northgate Express | 185.00 | 0.00 | 185.00
201 | Hillcrest Passenger | 70.00 | 0.00 | 70.00
301 | Market Day Fast | 190.00 | 0.00 | 190.00
(4 rows)
The spreadsheet, and the seat it sold twice
Section 5 builds bookings_flat, one row per booking with the passengers in one cell and
the seats in another, loaded with the same twelve bookings. Who sits in S1-1 of the 101 on the 4th,
the obvious way:
booking_code | passengers | seats
--------------+-----------------------------------------+------------------
T5BM9K | Elena Rossi, Kwame Mensah, Hannah Weber | S1-1, S1-2, S1-3
V3QF8N | Omar Haddad | S1-11
(2 rows)
LIKE '%S1-1%' matches S1-11 as well, and the row it should have found names three
people with no way to tell which of them has which seat. Worse, a new flat booking for Ngozi
Okafor in F1-1 on the same train and date is accepted without a word, and finding the double sale
afterwards means cutting every seat list apart with STRING_SPLIT or
string_to_table:
seat | times_sold
------+------------
F1-1 | 2
(1 row)
The proper tables answer the first question with one row and refuse the second insert, as section 3a showed:
seat | full_name | booking_code
------+-------------+--------------
S1-1 | Elena Rossi | T5BM9K
(1 row)
What this design does not do
Liam accepted the simpler seat rule, and the schema enforces only that: one confirmed passenger per seat per date, not per segment, so a seat free from Eastport onwards stays unsold. Several other rules are left to the booking procedure and stated here so the post does not claim them: that the seat belongs to the booking's train and class, that the boarding stop comes before the alighting stop, that the train runs on the journey date, that waitlist positions have no gaps, and that fare bands do not overlap. The cancellation and the promotions happen in one transaction in the script; nothing stops a clerk doing them in two.
The three habits this project teaches: a route is rows of stops, not a string of names; a price that may change tomorrow is copied onto the ticket today; and a rule that applies only to some rows is a filtered unique index, which both engines spell the same way.
Frequently asked
- What tables does a railway reservation system database need?
- Eleven in this design: stations, trains, route_stops (train, order, station, arrival, departure, distance), train_days, coaches, seats, fares (class and distance band), passengers, bookings (train, date, boarding and alighting station, class, a code), booking_passengers (the seat assignment with status, waitlist position and the fare charged) and cancellations. Seat availability, the chart and revenue are queries.
- How do you prevent the same seat being booked twice in SQL?
- With a filtered unique index on the seat assignment table: CREATE UNIQUE INDEX ux_booking_passengers_seat_date ON booking_passengers (seat_id, journey_date) WHERE status = 'confirmed'. Waitlisted rows have no seat and cancelled rows keep theirs as history, and neither is in the index. A plain unique constraint on the same columns failed on this data on both engines, for different reasons: PostgreSQL on the cancelled-plus-confirmed pair, SQL Server on the NULL seats of waitlisted rows.
- Why is storing seats as a comma-separated list a mistake?
- Because LIKE '%S1-1%' also matches S1-11, the row that matches names three people with no seat each, and a second sale of F1-1 for the same train and date is accepted without any error; finding it afterwards needs STRING_SPLIT or string_to_table over every row. One row per passenger with a seat_id, protected by a unique index, answers the question with one row and refuses the double sale.
Next: Part 9, an online shop: variants, carts that are not orders, orders that keep their prices, shipments as a history of events, and one review per customer per product.