- Model availability as one row per (room type or listing, night). A booking for 3 nights touches 3 rows; all must succeed or none do.
- Prevent double booking inside the database: a conditional update (
reserved < total) across the nights in one transaction, a unique constraint per night, or a PostgreSQL exclusion constraint on date ranges. - Use a hold with TTL while the guest pays, then confirm or release. Payment is idempotent on the reservation ID.
- Search is retrieve (geo + filters) → availability filter → rank. Keep a compact availability bitmap or calendar in the search layer, and always re-check at booking time.
- Hotels can overbook by a forecast margin; unique homes can't. Booking is strongly consistent; search and listings are eventually consistent.
Booking systems look like ordinary CRUD until two people try to book the same Goa villa for New Year's Eve at the same second. The interesting parts of "Design Airbnb" or "Design a hotel reservation system" are almost entirely about dates: how you store them, how you lock them, how you search over them and how you price them. This guide puts those first and keeps the rest (profiles, reviews, messaging) brief.
Airbnb vs Hotel Inventory: Clarify First
Ask the interviewer which one they mean. The inventory model differs in a way that changes the schema:
| Aspect | Airbnb-style (unique homes) | Hotel / OTA (MakeMyTrip, Booking.com, OYO) |
|---|---|---|
| Unit of inventory | One listing = one unit | Room type with a count (e.g. 40 Deluxe King rooms) |
| Specific unit chosen at booking? | Yes, the listing itself | No; a room number is assigned at check-in |
| Overbooking | Not possible | Common, based on no-show forecasts |
| Inventory owned by | Platform (host manages calendar on it) | Hotel PMS; shared across many channels |
| Instant vs request | Instant Book or host approval | Instant |
A clean design handles both: a hotel room type is a "listing" with total_units = 40; an Airbnb home is a listing with total_units = 1.
Requirements
Functional
- Hosts/hotels create listings, set calendars (blocked dates, min stay), base prices and per-date overrides.
- Guests search by location, dates, guests, price range and amenities; view a listing with its calendar and total price.
- Book (instant or request-to-book), pay, cancel per the cancellation policy, get refunds.
- Reviews and messaging (mention, but out of scope for depth).
Non-functional
- No double bookings, ever. This is the correctness bar.
- Search latency under ~300 ms; booking latency within a couple of seconds including payment authorisation.
- High availability for search; booking may briefly refuse during failures rather than risk inconsistency.
- Read-heavy: lookers vastly outnumber bookers.
Estimates
Assumptions for a large global platform:
- 5 million listings (homes + hotel room types); calendar horizon 365 nights.
- Calendar rows: 5M × 365 ≈ 1.8 billion listing-nights. At ~40 bytes per row (IDs, date, counts, price override) ≈ 75 GB plus indexes. Sharded relational storage handles this comfortably.
- Searches: 100M/day ≈ 1,160 QPS average, ~5,000 QPS peak (evenings, long weekends, Diwali/New Year planning).
- Listing page views: 300M/day ≈ 3,500 QPS.
- Bookings: 1M/day ≈ 12/s average, maybe 100/s peak. Each touches ~3 nights on average.
Read:write is roughly 1,000:1. Writes are few but contended: the same popular listing for the same weekend can see many simultaneous attempts. The problem isn't throughput; it's correctness under contention and search over a date dimension.
API
GET /v1/search?lat=15.55&lng=73.75&checkin=2026-12-29&checkout=2027-01-02
&guests=4&min_price=3000&max_price=12000&amenities=pool,wifi&cursor=..
GET /v1/listings/{id}?checkin=...&checkout=... → details + price breakdown
GET /v1/listings/{id}/calendar?from=2026-12-01&to=2027-02-28
POST /v1/quotes { listing_id, checkin, checkout, guests }
→ { quote_id, nightly: [...], fees, taxes, total, expires_at }
POST /v1/reservations { quote_id, payment_method } Idempotency-Key: ...
→ { reservation_id, status: "held" | "confirmed" | "requested" }
POST /v1/reservations/{id}/cancel
PUT /v1/host/listings/{id}/calendar { dates: [...], available: false, price: 9500 }
The quote step freezes the price the guest saw, so a host changing the price mid-checkout doesn't change what the guest pays, and gives you a natural place to validate min-stay and occupancy rules before you touch inventory.
The Availability Calendar Model
There are two ways to store availability. Interviewers like it when you compare them before choosing.
Option A: derive availability from bookings
Store only reservations (listing_id, check_in, check_out). A listing is free for [in, out) if no active reservation overlaps: existing.check_in < :out AND existing.check_out > :in. Simple and compact, natural for unique homes. Weak spots: no place for per-night prices, blocked dates and min-stay rules; and counting overlapping reservations per night for a 40-room hotel is awkward.
Option B: one inventory row per night (recommended)
CREATE TABLE listing (
listing_id BIGINT PRIMARY KEY,
host_id BIGINT NOT NULL,
kind TEXT NOT NULL, -- home | hotel_room_type
total_units INT NOT NULL DEFAULT 1,
base_price INT NOT NULL, -- in paise / minor units
currency CHAR(3) NOT NULL,
min_nights SMALLINT NOT NULL DEFAULT 1,
max_guests SMALLINT NOT NULL,
lat DOUBLE PRECISION, lng DOUBLE PRECISION
);
CREATE TABLE inventory_night (
listing_id BIGINT NOT NULL,
night DATE NOT NULL, -- the night starting on this date
total_units INT NOT NULL,
reserved_units INT NOT NULL DEFAULT 0,
blocked BOOLEAN NOT NULL DEFAULT false,
price_override INT, -- NULL => use pricing rules
overbook_pct SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (listing_id, night),
CHECK (reserved_units >= 0)
);
CREATE TABLE reservation (
reservation_id BIGINT PRIMARY KEY,
listing_id BIGINT NOT NULL,
guest_id BIGINT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL, -- exclusive
units INT NOT NULL DEFAULT 1,
status TEXT NOT NULL, -- held|confirmed|cancelled|expired|requested
hold_expires_at TIMESTAMPTZ,
total_amount BIGINT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (check_out > check_in)
);
Note the convention: a stay from 24 to 27 December consumes the nights of 24, 25 and 26. The check-out date is exclusive. Getting this wrong is the most common off-by-one bug in booking systems: it either blocks the day the guest leaves (so the next guest can't check in that afternoon) or lets two stays overlap by a night.
Shard inventory_night and reservation by listing_id, so every booking transaction is single-shard. Rows for past nights can be archived; rows for future nights beyond the horizon are created by a daily job ("roll the calendar forward one day"). For index design details see database indexing explained.
Concurrent Reservations: Never Double Book
The naive flow, SELECT to check availability, then INSERT the booking, fails when two requests both read "available" before either writes. Three correct techniques:
Technique 1: conditional update across nights (works for hotels and homes)
import java.sql.*;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public final class BookingRepository {
public long hold(Connection c, long listingId, LocalDate in, LocalDate out,
long guestId, long amount, String idemKey) throws SQLException {
int nights = (int) ChronoUnit.DAYS.between(in, out);
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
String sql =
"UPDATE inventory_night SET reserved_units = reserved_units + 1 " +
" WHERE listing_id = ? AND night >= ? AND night < ? " +
" AND blocked = false " +
" AND reserved_units + 1 <= total_units + (total_units * overbook_pct) / 100";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, listingId);
ps.setObject(2, in);
ps.setObject(3, out);
if (ps.executeUpdate() != nights) { // some night was full
c.rollback();
throw new NotAvailableException(listingId, in, out);
}
}
long id;
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO reservation (reservation_id, listing_id, guest_id, " +
" check_in, check_out, status, hold_expires_at, total_amount, idempotency_key) " +
"VALUES (nextval('reservation_seq'), ?, ?, ?, ?, 'held', " +
" now() + interval '10 minutes', ?, ?) RETURNING reservation_id")) {
ps.setLong(1, listingId);
ps.setLong(2, guestId);
ps.setObject(3, in);
ps.setObject(4, out);
ps.setLong(5, amount);
ps.setString(6, idemKey);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
id = rs.getLong(1);
}
}
c.commit();
return id;
} catch (SQLException e) {
c.rollback();
throw e;
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
class NotAvailableException extends RuntimeException {
NotAvailableException(long listingId, LocalDate in, LocalDate out) {
super("listing " + listingId + " not available " + in + " to " + out);
}
}
Why it's safe: in PostgreSQL or MySQL InnoDB, an UPDATE takes a row lock on each matching night. A concurrent transaction updating the same night waits, then re-evaluates the WHERE clause against the committed value. If the night is now full, that row isn't updated, the count comes back short, and the transaction rolls back. Note also that if the calendar has no row for a night (beyond the horizon), the count is short and the booking correctly fails. (The overbooking expression uses integer division; with overbook_pct = 0 it reduces to reserved_units + 1 <= total_units.)
Deadlock note: two transactions locking overlapping date ranges should acquire rows in the same order. A range UPDATE on the primary key (listing_id, night) scans in index order, so they do; if you update nights one by one in application code, sort the dates first. Retry on deadlock errors anyway.
Technique 2: unique constraint per booked night (unique homes)
Insert one row per night into booked_night(listing_id, night) with a primary key. Two overlapping bookings collide on at least one night and the second insert fails with a unique violation. Simple and very robust, but only for single-unit listings.
Technique 3: PostgreSQL exclusion constraint on date ranges
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE stay (
reservation_id BIGINT PRIMARY KEY,
listing_id BIGINT NOT NULL,
period DATERANGE NOT NULL, -- '[2026-12-24,2026-12-27)'
status TEXT NOT NULL,
EXCLUDE USING gist (listing_id WITH =, period WITH &&)
WHERE (status IN ('held', 'confirmed'))
);
The database itself rejects any active stay that overlaps another for the same listing. Elegant and impossible to bypass from application code; Postgres-specific and single-unit only.
End-to-End Booking Flow
- Quote. Compute nightly prices, fees and taxes; return a
quote_idvalid for ~15 minutes. - Hold. Conditional update of nights + insert reservation with status
heldandhold_expires_at = now + 10 min(transaction above). Idempotency key prevents duplicate holds on double-click. - Pay. Authorise the payment (card auth or UPI) with the reservation ID as the idempotency key. For many stays, platforms authorise or collect at booking and pay out to the host after check-in.
- Confirm. On payment success:
UPDATE reservation SET status='confirmed' WHERE id=? AND status='held'. Send confirmations; notify host; push to the hotel's PMS if applicable. - Expire. A sweeper finds holds past expiry and, in one transaction, sets
expiredand decrementsreserved_unitsfor those nights. The conditional status update (WHERE status='held') makes confirm and expire mutually exclusive. - Cancel. Same release transaction plus a refund computed by the cancellation policy (flexible, moderate, strict), handled by the payment service. Payment idempotency and refunds are detailed in payment system design.
Request-to-book listings: create the reservation as requested with a hold of 24 hours (inventory is held during the decision, or not, depending on product policy), authorise payment, and confirm or release on host response or timeout.
This hold-then-confirm shape is the same one you'd use for cinema seats; compare BookMyShow design, where the unit is a specific seat and holds are minutes long.
Search with Location, Filters and Dates
Search answers: listings near Goa, for 4 guests, with a pool, under ₹12,000/night, available 29 Dec to 2 Jan, ranked by relevance. The first four filters are classic search-engine work; the date filter is what makes this hard, because availability changes with every booking.
Stage 1: retrieve candidates
An Elasticsearch/OpenSearch index with one document per listing: geo point, guests, amenities, property type, base price, rating, and host settings. Geo filtering (bounding box of the map or distance from a point) works as in a proximity service. This narrows 5M listings to a few thousand candidates.
Stage 2: availability filter
Options, in increasing sophistication:
- Batch check against the availability service: send candidate IDs + dates; it answers from the inventory DB (or a cache of it). Simple, accurate, but a few thousand row-range checks per search.
- Availability bitmap in the index: store a 365-bit "free nights" bitmap per listing (46 bytes) in the search document or a side cache, updated on every booking/calendar change via CDC. Filtering is a bitwise AND.
- Precomputed for hotels: store remaining units per night, since a 40-room hotel isn't a binary free/busy.
from datetime import date
class AvailabilityBitmap:
"""Bit i = 1 means the listing is free on the night of (start + i days)."""
def __init__(self, start: date, days: int = 365):
self.start, self.days = start, days
self.bits = (1 << days) - 1 # all free
def _mask(self, check_in: date, check_out: date) -> int:
a = (check_in - self.start).days
n = (check_out - check_in).days # nights; check-out night not needed
if a < 0 or n <= 0 or a + n > self.days:
raise ValueError("out of range")
return ((1 << n) - 1) << a
def is_free(self, check_in, check_out) -> bool:
m = self._mask(check_in, check_out)
return self.bits & m == m
def book(self, check_in, check_out):
m = self._mask(check_in, check_out)
if self.bits & m != m:
raise RuntimeError("conflict")
self.bits &= ~m
cal = AvailabilityBitmap(date(2026, 12, 1))
cal.book(date(2026, 12, 24), date(2026, 12, 27)) # nights 24, 25, 26
print(cal.is_free(date(2026, 12, 20), date(2026, 12, 24))) # True (checkout on 24th)
print(cal.is_free(date(2026, 12, 26), date(2026, 12, 28))) # False (26th is taken)
print(cal.is_free(date(2026, 12, 27), date(2026, 12, 31))) # True
The bitmap in search is a hint, not a guarantee: it lags the database by a second or two, so the booking transaction re-checks. Occasionally a guest will click a listing that just got booked; show "no longer available for these dates" with alternatives.
Stage 3: rank
Rank the available candidates by a learned model: relevance to the query, price competitiveness, reviews, conversion history, host responsiveness, cancellation rate, distance to the map centre, and personalisation. Compute prices for the requested dates (stage 2 can return a cached total) so the price shown in results matches the listing page.
Caching search
Exact query caching has a low hit rate (dates × guests × filters × map bounds). Cache intermediate results instead: candidate sets per geo cell and guest count for a few minutes, and listing summaries by ID.
Pricing: Every Night Has Its Own Price
The price of a stay is a sum over nights plus stay-level adjustments:
| Component | Example | Where it lives |
|---|---|---|
| Base nightly price | ₹8,000 | listing.base_price |
| Weekend / seasonal rule | Fri–Sat +20%; 20 Dec–5 Jan +60% | Pricing rules table |
| Per-date override | 31 Dec: ₹25,000 | inventory_night.price_override |
| Length-of-stay discount | 7+ nights −10%; 28+ nights −25% | Listing settings |
| Fees | Cleaning ₹1,500 per stay; extra guest ₹800/night above 4 | Listing settings |
| Platform service fee | A percentage of subtotal | Platform config |
| Taxes | GST on accommodation in India depends on the tariff slab and changes with policy; compute per current rules | Tax service |
Order of operations matters (do discounts apply before or after cleaning fees? is tax computed per night tariff or on the total?), so put pricing in a single Pricing Service with versioned rules, used by search, listing page, quote and invoice alike. If search and checkout compute prices differently, guests see a different total at checkout, which hurts trust and is a regulatory risk.
Dynamic pricing (suggested prices for hosts, revenue management for hotels) runs offline: models predict demand per area and date from search volume, booking pace and events (a concert in Mumbai, a long weekend), and write suggested overrides. The online system only reads the resulting per-night prices.
Store all money as integers in minor units with a currency code; convert for display with a cached FX rate, but charge in a defined currency.
Overbooking and Channel Synchronisation
Deliberate overbooking (hotels)
If a hotel historically sees 5% no-shows and late cancellations for a given season, selling exactly 40 rooms means about 2 sit empty on average. Revenue management allows selling, say, 42. The schema supports this with overbook_pct per night. The risk is "walking" a guest: if everyone shows up, the hotel relocates someone to a comparable hotel and typically pays for it. Platforms like Airbnb don't overbook because a single home can't be "mostly" available.
Accidental overbooking: multi-channel inventory
A hotel sells the same rooms on its own website, MakeMyTrip, Booking.com, Agoda and walk-ins. The hotel's Property Management System (PMS) or a channel manager is the source of truth and pushes availability to each OTA. Between a booking on one channel and the update reaching the others, a second channel can sell the last room. Mitigations:
- Push updates immediately on every booking (webhooks/APIs) rather than periodic syncs.
- Keep a small buffer: don't expose the last 1–2 rooms on third-party channels.
- Allocation: give each channel a fixed allotment of rooms instead of the shared pool (less flexible, no oversell).
- Real-time availability check against the PMS at booking time for high-risk dates.
As the OTA in an interview, say: "our inventory is a cached copy of the hotel's; we re-confirm with the hotel's system at booking, and if the hotel rejects, we cancel and refund immediately, with rebooking support."
Architecture
- Booking Service owns inventory and reservations and is the only writer of those tables.
- CDC streams inventory changes to the search indexer (availability bitmaps), analytics, and channel sync.
- Listing data (photos, descriptions) is served from cache/CDN; photos from object storage.
- Hold sweeper and calendar roller are scheduled jobs.
Consistency Choices
| Data | Consistency | Reason |
|---|---|---|
| Inventory, reservations | Strong (single-leader SQL per shard, ACID transactions) | No double booking; prefer to reject during failover |
| Payment state | Strong, idempotent | Money |
| Search index, availability bitmaps | Eventual (seconds) | Re-checked at booking; huge read volume |
| Listing details, photos, reviews | Eventual (seconds to minutes) | Rarely changes, cacheable |
| Channel/OTA availability | Eventual, with buffers and re-confirmation | External systems |
In CAP terms, the booking path is CP: during a partition between the booking service and the primary database of a shard, bookings for those listings fail fast with a retry message. Search is AP: it keeps serving slightly stale results. Explaining that split clearly is one of the best signals you can give in this interview.
Multi-region: pin each listing's inventory to a home region (where its shard's primary lives). Guests anywhere write to that region for bookings; reads are served locally. Multi-primary writes for the same listing would require conflict resolution, and "two guests both got the villa" isn't a conflict you can merge.
Interview Follow-ups and Mistakes
Follow-ups
- "Two users book the last room at the same instant." Walk through the row locks: the second transaction blocks, re-evaluates
reserved_units + 1 <= total_units, updates fewer rows than nights, rolls back, and gets "not available". - "A host blocks dates while a guest is mid-checkout." Blocking is an update to the same rows (
blocked = true WHERE reserved_units = 0); either the hold or the block wins atomically. If the hold won, the host sees the dates as booked. - "How do you handle a booking that spans the calendar horizon?" Missing nights mean fewer updated rows: the booking fails. Extend the horizon or reject stays beyond it in validation.
- "How do you scale for New Year's Eve in Goa?" Reads: cache and pre-compute search candidates. Writes: contention is per listing, which is sharded; per-row locks serialise only bookings of the same listing, which is exactly what should be serialised.
- "How would you build the host's earnings dashboard?" From a ledger of payouts, fed by booking and payment events; read models updated asynchronously.
Common mistakes
- Check-then-insert without a lock or constraint.
- Off-by-one on check-out dates.
- Storing availability only in Elasticsearch and booking against it.
- A distributed lock as the only protection.
- Ignoring holds/expiry, so failed payments leave rooms locked forever.
- Pricing computed differently in search and checkout.
Get the per-night calendar, the atomic hold transaction and the search-then-recheck flow right, and the remaining discussion (pricing, overbooking, channels) becomes an opportunity to show product sense. For more practice on contended inventory, try the flash sale design, where the same "hold, confirm or expire" pattern meets a hundred-thousand-requests-per-second spike.
Frequently Asked Questions
Design Booking Systems with Confidence
Prepflix's system design course covers reservation systems like Airbnb, BookMyShow and IRCTC with live mock interviews.
Explore the Prepflix Course →