GuideApril 23, 2026·By Crew

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