How We Measure Room Turnover Time — The Technical Breakdown
Technical companion to Room Turnover Time in The Numbers That Matter series.
Turnover time is the gap between one member's checkout and the next member's check-in for the same room. The engineering challenge is decomposing that gap into its stages so you can see where time is actually going.
The turnover timeline
A complete turnover has four timestamps:
1. previous_checkout_at -- member checks out, room becomes "dirty"
2. cleaning_started_at -- cleaner claims the task
3. cleaning_completed_at -- cleaner marks it done, room becomes "available"
4. next_checkin_at -- next member checks into the room
From these four timestamps, you get three measurable gaps:
Queue Delay = cleaning_started_at - previous_checkout_at
Cleaning Time = cleaning_completed_at - cleaning_started_at
Vacancy Time = next_checkin_at - cleaning_completed_at
Total Turnover = next_checkin_at - previous_checkout_at
Building the turnover record
The turnover isn't a single table row — it's assembled by linking consecutive rentals for the same room with the cleaning task between them:
WITH ordered_rentals AS (
SELECT
r.room_id,
r.ended_at AS checkout_at,
LEAD(r.started_at) OVER (
PARTITION BY r.room_id ORDER BY r.started_at
) AS next_checkin_at,
r.id AS rental_id,
LEAD(r.id) OVER (
PARTITION BY r.room_id ORDER BY r.started_at
) AS next_rental_id
FROM rentals r
WHERE r.status = 'completed'
)
SELECT
or_.*,
ct.started_at AS cleaning_started_at,
ct.completed_at AS cleaning_completed_at,
-- The three gaps in minutes
EXTRACT(EPOCH FROM (ct.started_at - or_.checkout_at)) / 60
AS queue_delay_min,
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 60
AS cleaning_time_min,
EXTRACT(EPOCH FROM (or_.next_checkin_at - ct.completed_at)) / 60
AS vacancy_time_min,
EXTRACT(EPOCH FROM (or_.next_checkin_at - or_.checkout_at)) / 60
AS total_turnover_min
FROM ordered_rentals or_
LEFT JOIN cleaning_tasks ct ON ct.room_id = or_.room_id
AND ct.created_at >= or_.checkout_at
AND ct.created_at < or_.next_checkin_at
WHERE or_.next_checkin_at IS NOT NULL;
The LEAD window function pairs each rental with the next rental for the same room. The cleaning task is matched by room and time window.
Averages by room type
SELECT
rt.name AS room_type,
COUNT(*) AS turnovers,
ROUND(AVG(queue_delay_min), 1) AS avg_queue_delay,
ROUND(AVG(cleaning_time_min), 1) AS avg_cleaning_time,
ROUND(AVG(vacancy_time_min), 1) AS avg_vacancy_time,
ROUND(AVG(total_turnover_min), 1) AS avg_total_turnover
FROM (above turnover query) t
JOIN rooms rm ON t.room_id = rm.id
JOIN room_types rt ON rm.room_type_id = rt.id
GROUP BY rt.name
ORDER BY avg_total_turnover DESC;
Output might look like:
room_type | turnovers | queue | clean | vacancy | total
-------------|-----------|-------|-------|---------|------
Deluxe Suite | 84 | 8.2 | 22.4 | 12.1 | 42.7
Standard | 312 | 5.1 | 11.8 | 8.3 | 25.2
Locker | 589 | 2.3 | 4.7 | 3.8 | 10.8
Now you can see that Deluxe Suites have a queue delay problem (8.2 minutes before a cleaner even starts), while Standard rooms have a vacancy problem (8.3 minutes sitting clean but unassigned).
Revenue cost of turnover
To quantify the dollar cost of turnover time:
SELECT
rt.name AS room_type,
ROUND(AVG(total_turnover_min), 1) AS avg_turnover_min,
rt.hourly_rate,
ROUND(AVG(total_turnover_min) / 60.0 * rt.hourly_rate, 2) AS cost_per_turnover,
COUNT(*) AS turnovers_per_period,
ROUND(
AVG(total_turnover_min) / 60.0 * rt.hourly_rate * COUNT(*), 2
) AS total_turnover_cost
FROM (turnover query) t
JOIN rooms rm ON t.room_id = rm.id
JOIN room_types rt ON rm.room_type_id = rt.id
GROUP BY rt.name, rt.hourly_rate;
This converts minutes into dollars. If a room earns $50/hour and averages 42 minutes of turnover, each turnover costs ~$35 in lost revenue. Multiply by turnovers per month and you have the total cost of the gap.
Turnover by day and hour
To see when turnover is worst (hint: it's your busiest nights):
SELECT
EXTRACT(DOW FROM checkout_at) AS day_of_week,
CASE
WHEN EXTRACT(HOUR FROM checkout_at) < 12 THEN 'Morning'
WHEN EXTRACT(HOUR FROM checkout_at) < 18 THEN 'Afternoon'
ELSE 'Evening'
END AS time_block,
ROUND(AVG(total_turnover_min), 1) AS avg_turnover,
COUNT(*) AS turnovers
FROM (turnover query) t
GROUP BY day_of_week, time_block
ORDER BY day_of_week, time_block;
If Saturday evening turnovers average 45 minutes while Tuesday afternoon averages 18, the delta is your staffing opportunity.
Setting improvement targets
The minimum achievable turnover for a room type is roughly: best cleaning time + 2 min buffer + 2 min reassignment. If your best cleaners do a Standard room in 8 minutes, your floor for Standard turnover is about 12 minutes. Anything above that is recoverable through process or communication improvements. The gap between your current average and the floor is your improvement budget.
Back to the main article: Room Turnover Time — The Invisible Bottleneck Capping Your Busiest Nights