Yuki Tanaka runs the front office of a 30-room hotel with a board that says which rooms are occupied and which are vacant, and about once a week somebody gives away a room that is not free. The board is the bug. This post designs the hotel's database from a conversation with her, eight tables and one view, built and loaded on SQL Server and PostgreSQL, with the one rule that matters most in a hotel enforced by the database itself: two reservations for the same room can never overlap. The refusals, the five daily questions and the board's wrong answers are all quoted from the runs.

Steps in this part
  1. Download the script for your engine: p07-sqlserver.sql or p07-postgresql.sql
  2. Run it: sqlcmd -S "(localdb)\MSSQLLocalDB" -I -f 65001 -i p07-sqlserver.sql, or psql -d postgres -c "CREATE DATABASE sp_p07" then psql -d sp_p07 -e -f p07-postgresql.sql
  3. Find the overlap rule in section 2: an EXCLUDE constraint on PostgreSQL, a trigger on SQL Server
  4. Read section 3b in your output: the overlapping booking refused, the back-to-back one accepted, the extension refused
  5. Check 4.4: Liam O'Brien's bill ends with a balance due of 8370.00 on both engines
  6. Check 5.3: the typed board disagrees with the reservations on three rooms
  7. Paste section 2 into the ER diagram tool and p07-sheet.csv into the normalization checker

The conversation

Yuki: We keep a board that says which rooms are occupied and which are vacant. The receptionist types the status in when a guest arrives or leaves. When the desk is busy, it waits. Today the board said 102 was occupied, but Fatima Al-Sayed had left at 10:40.

Designer: So the board repeats something the stays already know, late. Tell me about the rooms.

Yuki: Every room has a number and a floor. There are three kinds: single, double and suite. A single sleeps one, a double two, a suite up to four.

Designer: Does the price belong to the room or to the kind of room?

Yuki: To the kind. All doubles cost the same per night. But the price changes with the season: a double is 3800 a night until the end of October and 4800 from the first of November.

Designer: If a guest stays across that change, what does the bill say?

Yuki: Each night is charged at the rate of that night. Liam O'Brien came on 30 October and leaves on 2 November: two nights at 3800 and one at 4800.

Designer: Then a stay is a list of nights, and each night finds its own rate. How does a booking reach you?

Yuki: A guest phones or writes and asks for a room from one date to another. We note the name, phone, email and address, the dates and how many adults. One booking is one room. Some guests come back; David Chen stayed in October and is booked again for November.

Designer: Then the guest is written down once and every stay points to them. What must never happen between two bookings?

Yuki: Two guests in the same room on the same night. But one guest can leave in the morning and the next arrive that afternoon: Aisha Khan takes 201 on the day Liam leaves it.

Designer: So a booking holds the nights from the check-in date up to the night before the check-out date, and the check-out day is free for the next guest. And on the day itself?

Yuki: At check-in we write down the time the guest actually arrives, and at check-out the time they leave. Kwame Mensah is booked for today and has not arrived yet. Then there is laundry, room service, an airport transfer, an extra bed, each with a usual price. And payments: a deposit when they book or arrive, the rest at check-out. At check-out I want one number, what they still owe.

Designer: "Occupied" is a stay with an arrival time and no departure time yet; "arriving today" is a booking that starts today with no arrival time. Both are read from the reservations. The balance is room nights plus services minus payments, worked out from the rows. Nothing on the board needs typing, so the board can go.

From sentences to tables

Yuki said became the entity and the tableThere are three kinds: single, doubleand suite Room type room_types name, max_guestsA double is 3800 a night until the endof October and 4800 from November Rate season rate_seasons room_type_id, start_date, end_datenightly_rateA guest asks for a room from one dateto another Reservation reservations guest_id, room_id, check_incheck_out, adultsLaundry, room service, an airporttransfer, an extra bed Charge reservation_services reservation_id, service_idservice_date, quantity, unit_priceA deposit when they book or arrive,the rest at check-out Payment payments reservation_id, paid_at, amountmethod
Five sentences from the conversation and what each became. The charge table is a junction with a payload: which service, on which day, how many, at what price.
Yuki saidEntityColumns
three kinds, each sleeps a number of guestsroom_typesname, max_guests
the price belongs to the kind and changes with the seasonrate_seasonsroom_type_id, season_name, start_date, end_date, nightly_rate
every room has a number and a floorroomsroom_no, floor_no, room_type_id
name, phone, email, address; some come backguestswritten once; every stay points at the guest
a room from one date to another, for so many adults; arrival and departure timesreservationsguest_id, room_id, check_in, check_out, adults, checked_in_at, checked_out_at
laundry, room service, each with a usual price, charged to the stayservices, reservation_servicesthe charge keeps service_date, quantity and the unit_price actually charged
a deposit, then the restpaymentsreservation_id, paid_at, amount, method
each night at the rate of that nightview reservation_nightsone row per night with its season's rate, counted, not stored
occupied, vacant, what they owenothingderived from reservations and payments

The rule the board could not keep

On PostgreSQL the whole rule is one line inside the table. It needs the btree_gist extension, which the script creates, and it reads: for the same room, two date ranges must not overlap.

CREATE TABLE reservations (
    reservation_id  int GENERATED ALWAYS AS IDENTITY CONSTRAINT pk_reservations PRIMARY KEY,
    guest_id        int       NOT NULL CONSTRAINT fk_reservations_guests REFERENCES guests (guest_id),
    room_id         int       NOT NULL CONSTRAINT fk_reservations_rooms REFERENCES rooms (room_id),
    check_in        date      NOT NULL,
    check_out       date      NOT NULL,
    adults          smallint  NOT NULL CONSTRAINT df_reservations_adults DEFAULT 1,
    checked_in_at   timestamp(0) NULL,
    checked_out_at  timestamp(0) NULL,
    CONSTRAINT ck_reservations_dates CHECK (check_out > check_in),
    CONSTRAINT ck_reservations_adults CHECK (adults >= 1),
    CONSTRAINT ex_reservations_no_overlap EXCLUDE USING gist (room_id WITH =, daterange(check_in, check_out) WITH &&)
);

daterange(check_in, check_out) with two arguments is half-open: it holds the check-in night and stops before the check-out day, which is exactly Yuki's "one leaves in the morning, the next arrives that afternoon". SQL Server has no exclusion constraint, so the same rule is a trigger that looks for a clashing row after every insert or update and throws it back:

    SELECT TOP (1) @msg = CONCAT('Room ', rm.room_no, ' is already reserved from ', r.check_in,
                                 ' to ', r.check_out, ' (reservation ', r.reservation_id, ').')
    FROM inserted AS i
    JOIN reservations AS r
      ON r.room_id = i.room_id
     AND r.reservation_id <> i.reservation_id
     AND r.check_in < i.check_out
     AND r.check_out > i.check_in
    JOIN rooms AS rm ON rm.room_id = r.room_id
    ORDER BY r.check_in;

    IF @msg IS NOT NULL
    BEGIN
        ROLLBACK TRANSACTION;
        THROW 50001, @msg, 1;
    END;

Section 3b tests both. A second booking for room 201 from 1 to 4 November, while Liam O'Brien holds it from 30 October to 2 November, is refused on both engines. PostgreSQL names the constraint and shows both ranges:

ERROR:  conflicting key value violates exclusion constraint "ex_reservations_no_overlap"
DETAIL:  Key (room_id, daterange(check_in, check_out))=(5, [2026-11-01,2026-11-04)) conflicts with existing key (room_id, daterange(check_in, check_out))=(5, [2026-10-30,2026-11-02)).

SQL Server raises error 50001 from the trigger with the message the trigger built:

Room 201 is already reserved from 2026-10-30 to 2026-11-02 (reservation 6).

Then Aisha Khan's booking for the same room from 2 November, the day Liam leaves, is accepted on both. And a third case the conversation did not ask for: extending Liam's stay by one night now collides with Aisha's booking and is refused too, which shows the rule covers updates as well as inserts. One small thing to notice in the listing afterwards: both engines skipped id 14, because the refused insert consumed it. Identity values are not a row count.

 reservation_id |  full_name   |  check_in  | check_out
----------------+--------------+------------+------------
              1 | Maria Garcia | 2026-10-03 | 2026-10-06
              6 | Liam O'Brien | 2026-10-30 | 2026-11-02
             15 | Aisha Khan   | 2026-11-02 | 2026-11-05
(3 rows)

Each night at its own rate

"Each night is charged at the rate of that night" is the sentence that decides the bill. The script answers it with a view that expands a stay into its nights and joins each night to the season it falls in:

CREATE VIEW reservation_nights AS
SELECT r.reservation_id,
       r.room_id,
       r.check_in + g.n AS night,
       s.season_name,
       s.nightly_rate
FROM reservations AS r
JOIN rooms AS rm ON rm.room_id = r.room_id
CROSS JOIN LATERAL generate_series(0, r.check_out - r.check_in - 1) AS g(n)
JOIN rate_seasons AS s
  ON s.room_type_id = rm.room_type_id
 AND r.check_in + g.n BETWEEN s.start_date AND s.end_date;

SQL Server 2022 has GENERATE_SERIES too, through CROSS APPLY with DATEADD for the arithmetic, as long as the database runs at compatibility level 160; the script checks that first. The bill and the revenue questions both read this view, so the rate of a night is decided in one place.

The ER diagram

ER diagram of the hotel database: room_types, rate_seasons, rooms, guests, reservations, services, reservation_services and payments with their foreign keys
Eight tables drawn by the ER diagram tool from section 2 of the PostgreSQL script. The view is not a table, so it is not in the picture.

Five questions the front desk asks

The sample loads 12 of the 30 rooms and fixes "today" at 2 November 2026 so that every answer is the same on every run. Which rooms are free from the 5th to the 8th: the same overlap test the constraint uses, turned into a NOT EXISTS.

SELECT rm.room_no, rt.name AS room_type
FROM rooms AS rm
JOIN room_types AS rt ON rt.room_type_id = rm.room_type_id
WHERE NOT EXISTS (
    SELECT 1
    FROM reservations AS r
    WHERE r.room_id = rm.room_id
      AND daterange(r.check_in, r.check_out) && daterange(DATE '2026-11-05', DATE '2026-11-08')
)
ORDER BY rm.room_no;
 room_no | room_type
---------+-----------
 102     | Single
 103     | Single
 104     | Single
 201     | Double
 202     | Double
 205     | Double
 301     | Suite
 302     | Suite
(8 rows)

Room 201 is in the list although Aisha's stay ends on the 5th, because a check-out day is not a reserved night. SQL Server writes the test as two date comparisons and returns the same eight. Today's arrivals and departures, with the time if it has happened:

 movement  | room_no |    full_name    |       done_at
-----------+---------+-----------------+---------------------
 arrival   | 201     | Aisha Khan      |
 arrival   | 203     | Elena Rossi     | 2026-11-02 13:10:00
 arrival   | 301     | Kwame Mensah    |
 departure | 102     | Fatima Al-Sayed | 2026-11-02 10:40:00
 departure | 201     | Liam O'Brien    |
(5 rows)

Occupancy per night for the week, from the nights view against the room count:

   night    | rooms_reserved | rooms_total | occupancy_pct
------------+----------------+-------------+---------------
 2026-11-02 |              5 |          12 |          41.7
 2026-11-03 |              5 |          12 |          41.7
 2026-11-04 |              4 |          12 |          33.3
 2026-11-05 |              4 |          12 |          33.3
 2026-11-06 |              2 |          12 |          16.7
 2026-11-07 |              2 |          12 |          16.7
 2026-11-08 |              1 |          12 |           8.3
(7 rows)

The bill of reservation 6, Liam O'Brien's three nights across the season change, with his services and his deposit. This is the one number Yuki asked for, and it is a query, not a column:

                  item                   |  amount
-----------------------------------------+----------
 Room 201, Regular, 2 night(s) x 3800.00 |  7600.00
 Room 201, Peak, 1 night(s) x 4800.00    |  4800.00
 Laundry x 4                             |   320.00
 Room service x 1                        |   650.00
 Payment, card, 2026-10-30               | -5000.00
 Balance due                             |  8370.00
(6 rows)

And room revenue per type for October, counted by the night's date, so a stay that crosses into November contributes only its October nights:

 room_type | room_nights | room_revenue
-----------+-------------+--------------
 Double    |           9 |     34200.00
 Suite     |           4 |     28000.00
 Single    |           6 |     15000.00
(3 rows)

The board, and why it was wrong three times

Section 5 rebuilds the receptionist's board as a table, rooms_with_status, exactly as it stood at 14:00 on 2 November, and asks it the walk-in question: which rooms can I give a guest tonight?

 room_no | room_type | status
---------+-----------+--------
 101     | Single    | vacant
 104     | Single    | vacant
 202     | Double    | vacant
 203     | Double    | vacant
 204     | Double    | vacant
 205     | Double    | vacant
 301     | Suite     | vacant
 303     | Suite     | vacant
(8 rows)

It offers 203, where Elena Rossi checked in at 13:10, and 301, which Kwame Mensah arrives for tonight, and it misses 102, free since 10:40. The same question put to the reservations gives seven rooms, the right seven. And a query that compares the two, row by row, shows every place the board lied:

 room_no | typed_status | derived_status
---------+--------------+----------------
 102     | occupied     | vacant
 203     | vacant       | occupied
 301     | vacant       | arriving today
(3 rows)

The board has no dates either, so "free from the 5th to the 8th" cannot even be asked of it. A status column that a person types is a cache with no refresh; derive it. The receptionist's spreadsheet of the same board, with a services column holding lists like "Laundry, Room service", is what the normalization checker sees below.

The normalization checker's verdict on the hotel board spreadsheet
The board sheet pasted into the normalization checker. It finds the list in services and the facts that repeat with the room type; the typed status is a design mistake it cannot see, because nothing in the values gives it away. Real screenshot of the tool on this site.

What this design does not do

Cancellations and no-shows are not modelled; Yuki did not raise them, and they would add a status to the reservation, this time a real one, with the date it changed. Two rules are also left to the application: two seasons of one room type must not overlap, or a night would be priced twice, and adults is not checked against the type's max_guests. Neither was tested. And the SQL Server trigger was not tried with two sessions booking the same room at the same instant; the PostgreSQL constraint is backed by an index and holds under that too.

The three habits this project teaches: a stay is a range of nights, half-open; the rule that two stays cannot share a room belongs in the database, not on a board; and the price of a night belongs to the night, which a view can work out every time.

Frequently asked

How do you prevent double booking of a hotel room in the database?
On PostgreSQL with an exclusion constraint: EXCLUDE USING gist (room_id WITH =, daterange(check_in, check_out) WITH &&) after CREATE EXTENSION btree_gist. The half-open range lets the next guest check in on the check-out day. SQL Server has no exclusion constraint, so an AFTER INSERT, UPDATE trigger looks for a clashing reservation and throws; both refuse the overlap and both accept the back-to-back booking in this post's run.
What tables does a hotel management system database need?
Eight here: room_types, rate_seasons (the nightly rate per type per season), rooms, guests, reservations (one room, a date range, arrival and departure times), services, reservation_services (the charges, with date, quantity and the price actually charged) and payments. Occupancy, the bill and the balance are queries; a view that expands a stay into its nights gives each night its own rate.
Why is a room status column a mistake in a hotel database?
Because somebody has to type it, and when the desk is busy it waits. In this post's sample the board was wrong for three of twelve rooms at 14:00: a room marked occupied whose guest had left, a room marked vacant whose guest had checked in, and a room with an arrival due that the board could not express. The status derived from reservations was right for all twelve.

Next: Part 8, a railway reservation system: routes as ordered stops, seats that cannot be given twice for the same date, fares, bookings and a waitlist.