Calculating Overbooking Buffers from Historical No-Show Rates
A room reserved for tonight is not the same as a room occupied tonight, and the gap between the two — guests who cancel late or simply never arrive — is money a property leaves on the table unless it deliberately oversells to cover it. This page derives a defensible overbooking buffer per room type and date from historical no-show and cancellation behaviour, as the statistical companion to the overbooking prevention and inventory buffers guide that consumes the buffer table this job produces. The wrong buffer costs real money in both directions: too small and you sell out with empty rooms at check-out; too large and you walk a guest and eat the compensation.
The method is expected-value sizing. For a given room type and date, you know roughly what fraction of committed reservations will not materialize. Overbook by fewer rooms than that expected no-show count and you are being conservative; overbook by more and you take on walk risk. The right buffer is the point where the marginal expected revenue of filling one more no-show seat equals the marginal expected cost of walking a guest — and that point depends on this room type’s own history, not a portfolio-wide average.
Prerequisites and environment
This job runs as a scheduled batch over historical reservations and writes a buffer table keyed by (room_type_code, stay_date) that the availability recalculation reads. Pin these so the arithmetic is reproducible across runs:
- Python 3.11+ — for modern type hints and stable
statisticsbehaviour. polars1.x — the whole pipeline is a vectorized group-by over potentially millions of historical reservation rows; a per-row Python loop would not finish in the maintenance window.pydantic2.6+ — the emitted buffer record is validated so a nonsensical buffer (negative, or larger than physical inventory) can never reach the availability worker.structlog24.x — each room type’s derived buffer is logged as a key=value event so a revenue manager can audit why a date was overbooked by a specific amount.- Historical data — at least twelve months of reservation records carrying
room_type_code,stay_date, a terminalstatus(arrived,no_show,cancelled_late), and the booking’smarket_segment, so no-show behaviour can be segmented rather than averaged flat.
The economic inputs — average recoverable revenue per room and the all-in walk cost — should come from finance, not be guessed. Walk cost is not just the competitor’s room rate; it includes transport, a goodwill gesture, and the lifetime-value hit of a soured guest, which is why it dwarfs the nightly rate for premium room types.
Step-by-step implementation
The pipeline is four passes: measure the historical no-show rate per segment, translate it into an expected no-show count for a date’s commitments, convert that into a buffer via the expected-value trade-off, and attach a confidence band so the buffer shrinks when the sample is thin.
Step 1 — Measure the no-show rate per room type and segment
Aggregate terminal reservation outcomes into a no-show probability, segmented by room_type_code and market_segment because a non-refundable corporate booking behaves nothing like a flexible leisure one. Count late cancellations that free the room too late to resell as no-shows for buffer purposes.
import polars as pl
def no_show_rates(history: pl.DataFrame) -> pl.DataFrame:
# history: room_type_code, market_segment, status in {arrived, no_show, cancelled_late}
return (
history
.with_columns(
# a late cancel is functionally a no-show: the room could not be resold
(pl.col("status").is_in(["no_show", "cancelled_late"]))
.alias("is_noshow")
)
.group_by(["room_type_code", "market_segment"])
.agg(
pl.len().alias("n"),
pl.col("is_noshow").mean().alias("noshow_rate"),
)
)
Grouping by market_segment as well as room_type_code is the choice that makes the buffer honest: a flat property-wide rate over-buffers non-refundable inventory (which rarely no-shows) and under-buffers flexible OTA inventory (which frequently does), producing walks on exactly the dates you most wanted to protect.
Step 2 — Project the expected no-show count for a specific date
For a date’s actual commitment mix, the expected number of rooms that will free up is the sum over each segment of its committed rooms times its no-show rate. This turns a probability into a room count you can act on.
def expected_noshows(commitments: pl.DataFrame, rates: pl.DataFrame) -> pl.DataFrame:
# commitments: room_type_code, market_segment, stay_date, committed
return (
commitments
.join(rates, on=["room_type_code", "market_segment"], how="left")
.with_columns(pl.col("noshow_rate").fill_null(0.0)) # unseen segment → assume shows
.with_columns(
(pl.col("committed") * pl.col("noshow_rate")).alias("expected_noshow")
)
.group_by(["room_type_code", "stay_date"])
.agg(pl.col("expected_noshow").sum().alias("expected_noshow"))
)
Filling an unseen segment’s rate with 0.0 rather than the global average is the safe default: a segment you have never observed no-showing is assumed to arrive, so a novel booking mix errs toward under-overbooking (empty rooms) rather than over-overbooking (a walk).
Step 3 — Convert expected no-shows into a buffer via the cost trade-off
The buffer is not simply the expected no-show count — it is scaled by the ratio of the revenue you recover per filled room to the walk cost you risk. When walk cost dwarfs recoverable revenue (suites), the buffer shrinks below the expected no-show count; when they are comparable (commodity rooms), it approaches it.
from dataclasses import dataclass
@dataclass(frozen=True)
class RoomEconomics:
recover_value: float # expected revenue from filling one no-show seat
walk_cost: float # all-in cost of walking one guest (rate + transport + goodwill)
def buffer_from_expected(expected: pl.DataFrame,
econ: dict[str, RoomEconomics]) -> pl.DataFrame:
# critical ratio: how much of the expected no-show gap is worth capturing
def ratio(rtc: str) -> float:
e = econ[rtc]
return e.recover_value / (e.recover_value + e.walk_cost)
return expected.with_columns(
pl.struct(["room_type_code", "expected_noshow"]).map_elements(
lambda r: r["expected_noshow"] * ratio(r["room_type_code"]),
return_dtype=pl.Float64,
).alias("raw_buffer")
)
The critical ratio recover_value / (recover_value + walk_cost) is the newsvendor service level applied to overbooking: it caps the buffer at a fraction of the expected no-shows so a room type with a punishing walk cost never overbooks all the way to its statistical no-show count.
Step 4 — Apply a confidence band and round down safely
A no-show rate measured over fifty reservations is far less trustworthy than one over five thousand. Shrink the buffer toward zero when the sample is small using the lower bound of a normal-approximation interval, then round down so uncertainty always costs you an empty room rather than a walk.
import math
def apply_confidence(df: pl.DataFrame, rates: pl.DataFrame,
z: float = 1.28) -> pl.DataFrame: # ~80% one-sided
sample = rates.group_by("room_type_code").agg(pl.col("n").sum().alias("n"))
return (
df.join(sample, on="room_type_code", how="left")
.with_columns(
# widen the discount as n shrinks: buffer * (1 - z/sqrt(n))
(pl.col("raw_buffer") *
(1 - z / pl.col("n").cast(pl.Float64).sqrt()).clip(0.0, 1.0))
.alias("buffer_lb")
)
.with_columns(
pl.col("buffer_lb").floor().cast(pl.Int64).clip(0, None).alias("buffer_reserve")
)
.select(["room_type_code", "stay_date", "buffer_reserve"])
)
Rounding with floor() rather than round() is the asymmetry the whole job hinges on: the cost of an empty room is bounded and recoverable, while the cost of a walk is large and reputational, so every rounding decision should resolve in favour of selling one fewer room.
Gotchas and production notes
- Segment mix, not just volume, moves the buffer. Two dates with identical committed counts need different buffers if one is heavy with flexible OTA bookings and the other with non-refundable corporate ones. Always project expected no-shows from the actual per-segment mix (Step 2), never from a date-level total times a blended rate — the blend hides the risk.
- Late cancellations are no-shows for buffer math, but not for all reporting. A guest who cancels at 4pm for a same-night stay freed a room you almost certainly could not resell, so it counts toward the buffer exactly like a no-show. Keep this reclassification local to the buffer job; your finance cancellation metrics will define it differently, and conflating the two produces arguments nobody wins.
- Group blocks break the independence assumption. The expected-value math assumes reservations no-show independently. A twenty-room wedding block does not — it arrives or collapses together. Exclude group-block inventory from the statistical buffer and handle it with an explicit contractual allowance, or a single group cancellation will blow a hole no per-room buffer was sized to absorb.
- Recompute daily; a buffer is stale within a day. No-show behaviour shifts with day-of-week, lead time, and events. Rerun the pipeline on a daily schedule so the buffer the availability recalculation consumes reflects the current forward book, and feed the emitted rows through the same data schema standardization contract as every other payload.
Verification snippet
Confirm the two behaviours that keep the buffer safe: that a punishing walk cost shrinks the buffer below the raw expected no-show count, and that a thin sample shrinks it further toward zero.
import polars as pl
def test_high_walk_cost_shrinks_buffer():
expected = pl.DataFrame({"room_type_code": ["SUITE_EXEC"],
"stay_date": ["2026-08-14"], "expected_noshow": [4.0]})
econ = {"SUITE_EXEC": RoomEconomics(recover_value=300.0, walk_cost=1500.0)}
out = buffer_from_expected(expected, econ)
# ratio = 300 / 1800 ≈ 0.167 → raw_buffer well below 4 expected no-shows
assert out["raw_buffer"][0] < 1.0
def test_thin_sample_floors_to_conservative_buffer():
df = pl.DataFrame({"room_type_code": ["DLX_KING"],
"stay_date": ["2026-08-14"], "raw_buffer": [3.0]})
rates = pl.DataFrame({"room_type_code": ["DLX_KING"], "n": [16]}) # tiny sample
out = apply_confidence(df, rates)
# 1 - 1.28/sqrt(16) = 1 - 0.32 = 0.68 → 3.0 * 0.68 = 2.04 → floor → 2
assert out["buffer_reserve"][0] == 2
test_high_walk_cost_shrinks_buffer()
test_thin_sample_floors_to_conservative_buffer()
The first test proves the economics actually bind — a suite with a 1,500-unit walk cost must not overbook to its full statistical no-show count — and the second proves the confidence band does its job, pulling a buffer derived from only sixteen observations down to a level you can defend. In production, also assert that every emitted buffer_reserve is a non-negative integer no larger than the room type’s physical count before it reaches the availability worker.
Related
- Overbooking prevention and inventory buffers — the parent guide: how the buffer this job emits becomes a stop-sell decision.
- Implementing stop-sell automation in Python — the worker that acts on the sellable count once the buffer is subtracted.
- Batch reconciliation workflows — the scheduled loop this daily buffer job slots into alongside other nightly syncs.
- Data schema standardization — the payload contract the emitted buffer rows validate against.