How We Calculate Labor Cost Per Room — The Technical Breakdown
Technical companion to Labor Cost Per Room in The Numbers That Matter series.
Labor cost per room connects three data streams — staff time, pay rates, and room assignments — into a single per-room cost figure. It's the metric that turns revenue reports into profitability reports.
The data model
Three relationships make this work:
Room → Cleaning Tasks → Staff Member (with hourly rate)
Room → Maintenance Tasks → Staff Member (with hourly rate)
Room → Rentals → Revenue
Every task has a start time, end time, and assigned staff member. Every staff member has an hourly rate. The math follows naturally.
Labor cost for a single room
SELECT
rm.id AS room_id,
rm.name,
-- Cleaning labor
COALESCE(SUM(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 3600
* s_clean.hourly_rate
), 0) AS cleaning_cost,
-- Maintenance labor
COALESCE(SUM(
EXTRACT(EPOCH FROM (mt.completed_at - mt.started_at)) / 3600
* s_maint.hourly_rate
), 0) AS maintenance_cost,
-- Total labor
COALESCE(SUM(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 3600
* s_clean.hourly_rate
), 0) +
COALESCE(SUM(
EXTRACT(EPOCH FROM (mt.completed_at - mt.started_at)) / 3600
* s_maint.hourly_rate
), 0) AS total_labor_cost
FROM rooms rm
LEFT JOIN cleaning_tasks ct ON ct.room_id = rm.id
AND ct.completed_at >= :period_start
AND ct.completed_at < :period_end
LEFT JOIN staff s_clean ON ct.staff_id = s_clean.id
LEFT JOIN maintenance_tasks mt ON mt.room_id = rm.id
AND mt.completed_at >= :period_start
AND mt.completed_at < :period_end
LEFT JOIN staff s_maint ON mt.staff_id = s_maint.id
WHERE rm.id = :room_id
GROUP BY rm.id, rm.name;
Room-level P&L
The real payoff — combining labor cost with revenue for a per-room profit/loss:
WITH room_revenue AS (
SELECT
ren.room_id,
SUM(ren.rental_charge) AS total_revenue,
COUNT(*) AS total_rentals
FROM rentals ren
WHERE ren.status = 'completed'
AND ren.started_at >= :period_start
AND ren.ended_at < :period_end
GROUP BY ren.room_id
),
room_labor AS (
SELECT
room_id,
SUM(labor_cost) AS total_labor_cost,
SUM(task_count) AS total_tasks
FROM (
SELECT
ct.room_id,
SUM(EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 3600
* s.hourly_rate) AS labor_cost,
COUNT(*) AS task_count
FROM cleaning_tasks ct
JOIN staff s ON ct.staff_id = s.id
WHERE ct.completed_at BETWEEN :period_start AND :period_end
GROUP BY ct.room_id
UNION ALL
SELECT
mt.room_id,
SUM(EXTRACT(EPOCH FROM (mt.completed_at - mt.started_at)) / 3600
* s.hourly_rate) AS labor_cost,
COUNT(*) AS task_count
FROM maintenance_tasks mt
JOIN staff s ON mt.staff_id = s.id
WHERE mt.completed_at BETWEEN :period_start AND :period_end
GROUP BY mt.room_id
) combined
GROUP BY room_id
)
SELECT
rm.name AS room_name,
rt.name AS room_type,
COALESCE(rev.total_revenue, 0) AS revenue,
COALESCE(lab.total_labor_cost, 0) AS labor_cost,
COALESCE(rev.total_revenue, 0) - COALESCE(lab.total_labor_cost, 0) AS profit,
ROUND(
COALESCE(lab.total_labor_cost, 0) /
NULLIF(COALESCE(rev.total_revenue, 0), 0) * 100, 1
) AS labor_cost_pct_of_revenue,
COALESCE(rev.total_rentals, 0) AS rentals,
COALESCE(lab.total_tasks, 0) AS tasks
FROM rooms rm
JOIN room_types rt ON rm.room_type_id = rt.id
LEFT JOIN room_revenue rev ON rev.room_id = rm.id
LEFT JOIN room_labor lab ON lab.room_id = rm.id
WHERE rm.is_rentable = true
ORDER BY profit DESC;
Output:
room_name | type | revenue | labor | profit | labor_% | rentals | tasks
-----------|---------|---------|--------|--------|---------|---------|------
Room 12 | Std | $2,840 | $186 | $2,654 | 6.5% | 71 | 73
Room 3 | Std | $2,650 | $201 | $2,449 | 7.6% | 66 | 68
Room 8 | Deluxe | $4,120 | $892 | $3,228 | 21.7% | 42 | 51
Room 15 | Premium | $5,200 | $1,840 | $3,360 | 35.4% | 28 | 39
Room 15 looks great on revenue but burns 35% of it on labor. Room 12 is the quiet workhorse with a 6.5% labor ratio.
Labor cost per turnover
A more granular view — what does each individual room turnover cost?
SELECT
rt.name AS room_type,
COUNT(ct.id) AS cleanings,
ROUND(AVG(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 60
), 1) AS avg_cleaning_minutes,
ROUND(AVG(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 3600
* s.hourly_rate
), 2) AS avg_cost_per_cleaning
FROM cleaning_tasks ct
JOIN rooms rm ON ct.room_id = rm.id
JOIN room_types rt ON rm.room_type_id = rt.id
JOIN staff s ON ct.staff_id = s.id
WHERE ct.completed_at BETWEEN :period_start AND :period_end
GROUP BY rt.name
ORDER BY avg_cost_per_cleaning DESC;
This tells you the per-turnover labor cost by room type. If a Deluxe clean costs $18 and a Standard costs $5, that $13 difference needs to be reflected in your room pricing — or your Deluxe cleaning process needs streamlining.
Trending labor costs
To catch rooms with rising maintenance costs:
SELECT
rm.name,
DATE_TRUNC('month', mt.completed_at) AS month,
SUM(
EXTRACT(EPOCH FROM (mt.completed_at - mt.started_at)) / 3600
* s.hourly_rate
) AS monthly_maintenance_cost
FROM maintenance_tasks mt
JOIN rooms rm ON mt.room_id = rm.id
JOIN staff s ON mt.staff_id = s.id
WHERE mt.completed_at >= NOW() - INTERVAL '6 months'
GROUP BY rm.name, month
ORDER BY rm.name, month;
A room whose maintenance cost is climbing month over month is a candidate for capital investment — fix the root cause before the labor bleed continues.
Staff efficiency comparison
Sensitive but useful — average cleaning cost by staff member for the same room type:
SELECT
s.name AS cleaner,
rt.name AS room_type,
COUNT(*) AS cleanings,
ROUND(AVG(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 60
), 1) AS avg_minutes,
ROUND(AVG(
EXTRACT(EPOCH FROM (ct.completed_at - ct.started_at)) / 3600
* s.hourly_rate
), 2) AS avg_cost
FROM cleaning_tasks ct
JOIN staff s ON ct.staff_id = s.id
JOIN rooms rm ON ct.room_id = rm.id
JOIN room_types rt ON rm.room_type_id = rt.id
GROUP BY s.name, rt.name
ORDER BY rt.name, avg_cost;
Differences here are coaching opportunities, not performance reviews. Maybe the faster cleaner has a better supply cart setup, or a more efficient sequence for the room layout.
Back to the main article: Labor Cost Per Room — The Number That Turns Revenue Into Profit