Blended-Rate Overtime for Multiple Pay Rates: The Weighted-Average Regular Rate Under 29 CFR § 778.115
When a non-exempt employee works at two or more distinct hourly rates during a single workweek — a warehouse associate who also runs a forklift, a nurse who picks up a higher-paid charge-nurse shift — the FLSA regular rate is not either rate in isolation; it is the hours-weighted average of every rate the employee earned that week, and overtime is paid as a half-time premium on that blended figure. This pattern is part of the Overtime Calculation Engines topic within the Payroll Calculation Engines & Validation Rules framework, and it is a distinct calculation stage from jurisdictional daily-threshold rules: it resolves which regular rate overtime is computed against, not which hours qualify as overtime in the first place.
Problem Framing
An engine that already resolves daily-versus-weekly overtime thresholds correctly can still underpay an employee the moment that employee crosses rate codes mid-week, for three reasons that surface only when payroll data spans multiple job codes per employee per period:
- Overtime priced against the wrong single rate. The naive fix — multiply overtime hours by 1.5 times whichever rate was in effect during the overtime hours, or whichever rate is highest, or whichever rate the employee happened to be clocked into last — is not the default FLSA method. 29 CFR § 778.115 requires the regular rate for the entire week to be the weighted average of all rates, and the overtime premium is computed against that average, not against any single rate segment.
- Per-shift or per-rate-code aggregation instead of a weekly blend. Some timekeeping exports group hours by rate code and evaluate the 40-hour threshold independently within each group. That produces zero overtime hours if no single rate code individually exceeds 40 hours, even though the employee’s total weekly hours across all codes did. The 40-hour threshold, and the blended rate, are both computed over the whole workweek — never per rate code.
- Float drift compounding through a division. The blended rate is a quotient — total straight-time compensation divided by total hours — and that quotient is rarely a terminating decimal. Computing it with binary
floatdivision, then multiplying by overtime hours and a half-time factor, propagates rounding error into every affected paycheck, and the error is neither reproducible nor easy to explain to a Department of Labor auditor.
The controlling formula, per 29 CFR § 778.110 and § 778.115, computes the blended regular rate as total straight-time earnings across every rate divided by total hours worked:
Overtime compensation is then the straight-time earnings already paid for every hour worked, plus an additional half-time premium on the hours worked beyond 40 in the workweek:
Because straight time was already paid for every hour at its own segment rate, only the extra half — not a full 1.5× — is owed on the overtime hours. This is the standard multiple-rate weighted-average method for hourly employees; it is a different calculation from the fluctuating-workweek salary method and from California’s daily and seventh-consecutive-day triggers worked through in Calculating Double Overtime for California, which resolves how many hours are overtime under state daily rules before this page’s blended rate would ever apply.
Prerequisites & Data Requirements
This calculation consumes already-normalized, already-classified hourly segments — it does not resolve exempt status or jurisdictional daily thresholds itself. Every hour count and every rate is Decimal, never float, because the blended rate is a division result that later feeds a multiplication.
| Field | Type | Precondition |
|---|---|---|
rate_code |
str |
Identifies the job code or pay classification for the segment (audit trail only; does not affect the math). |
hours |
Decimal |
Hours worked at this rate in the workweek; must be >= 0. |
rate |
Decimal |
Hourly rate for this segment; must be > 0. |
employee_id |
str |
Used only for structured logging and audit correlation. |
Two preconditions sit outside the function signature:
- Segments are already scoped to one workweek. Upstream normalization has already partitioned punches into the employer’s defined workweek and attributed each punch to the correct
rate_code; this stage never re-derives workweek boundaries. - Non-exempt status is already confirmed. An employee must already be classified non-exempt before reaching this engine — that gate is a separate concern documented in the parent Overtime Calculation Engines topic.
Step-by-Step Implementation
Step 1 — Model rate segments and pin constants. Represent each rate segment as a frozen dataclass so the audit trail carries typed, validated fields, and reject non-positive rates or negative hours at construction.
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Tuple
import logging
logger = logging.getLogger("payroll.overtime.blended")
FORTY = Decimal("40")
HALF = Decimal("0.5")
CENTS = Decimal("0.01")
@dataclass(frozen=True)
class RateSegment:
rate_code: str
hours: Decimal
rate: Decimal
def __post_init__(self) -> None:
if self.hours < Decimal("0"):
raise ValueError(f"rate_code={self.rate_code} hours must be non-negative")
if self.rate <= Decimal("0"):
raise ValueError(f"rate_code={self.rate_code} rate must be positive")
@dataclass
class BlendedOvertimeResult:
total_hours: Decimal
straight_time_comp: Decimal
blended_rate: Decimal
ot_hours: Decimal
ot_premium: Decimal
gross_pay: Decimal
Step 2 — Sum straight-time compensation across every rate segment. Every segment contributes hours * rate to straight-time pay and its hours to the weekly total; this must run over all segments before any threshold check, never per rate code. Expected output for 25 hours at $18.00 plus 20 hours at $24.00: total_hours = Decimal("45"), straight_time_comp = Decimal("930.00").
def _sum_straight_time(segments: List[RateSegment]) -> Tuple[Decimal, Decimal]:
"""Sum hours and straight-time compensation across every rate segment."""
total_hours = Decimal("0")
straight_time_comp = Decimal("0")
for seg in segments:
comp = seg.hours * seg.rate
straight_time_comp += comp
total_hours += seg.hours
logger.info(
"brt_segment rate_code=%s hours=%s rate=%s comp=%s",
seg.rate_code, seg.hours, seg.rate, comp,
)
return total_hours, straight_time_comp
Step 3 — Compute the blended rate with Decimal. Divide total straight-time compensation by total hours per § 778.115. Leave the result at full Decimal precision — do not quantize it here, since rounding the rate before multiplying by overtime hours introduces the same drift the final quantize step is meant to prevent. Expected output: Decimal("20.66666666666666666666666667").
def _blended_rate(straight_time_comp: Decimal, total_hours: Decimal) -> Decimal:
"""Compute the § 778.115 weighted-average blended regular rate."""
if total_hours <= Decimal("0"):
raise ValueError("total_hours must be positive to compute a blended rate")
return straight_time_comp / total_hours
Step 4 — Apply the half-time premium on hours over 40 and quantize once. Straight time is already paid for every hour in Step 2, including the overtime hours, so only the additional half applies. Round only the final gross figure. Expected output for the 45-hour example: ot_hours = Decimal("5"), gross_pay = Decimal("981.67").
def calculate_blended_overtime(
segments: List[RateSegment], employee_id: str = "unknown",
) -> BlendedOvertimeResult:
"""Compute weekly overtime for an employee paid at two or more rates."""
total_hours, straight_time_comp = _sum_straight_time(segments)
blended_rate = _blended_rate(straight_time_comp, total_hours)
ot_hours = max(Decimal("0"), total_hours - FORTY)
ot_premium = ot_hours * blended_rate * HALF
gross_pay = (straight_time_comp + ot_premium).quantize(
CENTS, rounding=ROUND_HALF_UP
)
logger.info(
"brt_calculate emp=%s total_hours=%s blended_rate=%s ot_hours=%s "
"ot_premium=%s gross=%s",
employee_id, total_hours, blended_rate, ot_hours, ot_premium, gross_pay,
)
return BlendedOvertimeResult(
total_hours=total_hours,
straight_time_comp=straight_time_comp,
blended_rate=blended_rate,
ot_hours=ot_hours,
ot_premium=ot_premium,
gross_pay=gross_pay,
)
Step 5 — Call with concrete two-rate segments. Supply every segment the employee worked in the week; the function aggregates across all of them before evaluating the 40-hour threshold.
segments = [
RateSegment(rate_code="WAREHOUSE", hours=Decimal("25"), rate=Decimal("18.00")),
RateSegment(rate_code="FORKLIFT", hours=Decimal("20"), rate=Decimal("24.00")),
]
result = calculate_blended_overtime(segments, employee_id="E-70213")
# result.total_hours == Decimal("45")
# result.straight_time_comp == Decimal("930.00")
# result.blended_rate == Decimal("20.66666666666666666666666667")
# result.ot_hours == Decimal("5")
# result.gross_pay == Decimal("981.67")
Verification
Confirm correctness with cases specific to the blended-rate calculation, not just a single happy-path total:
- Two-rate blended value. Feed
25hours at$18.00and20hours at$24.00and assertblended_rate == Decimal("930.00") / Decimal("45")exactly — the division must not be pre-rounded before the assertion. - Premium priced on the blend, not on either raw rate. Assert the overtime premium is computed against
blended_rate(≈ $20.67/hr), not against$18.00,$24.00, the higher rate, or the rate active during the overtime hours. All three alternates produce a different, incorrect gross. - Boundary at exactly 40 hours. Feed
22hours at$18.00and18hours at$24.00(total40) and assertot_hours == Decimal("0")andgross_pay == Decimal("828.00")— the straight-time total with no premium added, even though the blended rate ($20.70) is well-defined. - Decimal determinism. Run the same two-rate input three times and assert byte-identical
gross_pay. Any run-to-run drift means afloatentered the rate or premium path. - Single-rate degeneration. Feed one segment only and assert the result matches the ordinary single-rate overtime calculation — the blended-rate method must reduce cleanly to the single-rate case when there is only one rate code.
Failure Modes
- Overtime priced against a single raw rate. Root cause: the engine multiplies overtime hours by 1.5× the rate active during the overtime hours, or by the highest of the employee’s rates, instead of computing the § 778.115 weighted average across every segment. Remediation: always compute
blended_ratefrom the full set of segments before pricing any overtime hour, and add a regression test asserting the premium changes when either segment’s rate changes, not just when the “last worked” rate changes. - Per-rate-code aggregation instead of a weekly blend. Root cause: the 40-hour threshold is evaluated independently within each
rate_codebucket, so an employee who worked 25 hours on one code and 20 on another shows zero overtime because no single code exceeded 40, even though total weekly hours were 45. Remediation: aggregatehoursacross every segment for the employee’s workweek before comparing to the 40-hour threshold; the threshold and the blend are both whole-week computations. - Float drift in the blended-rate division. Root cause: computing
straight_time_comp / total_hourswith binaryfloatintroduces rounding error that compounds when the result is later multiplied by overtime hours and0.5, producing gross pay that differs by a cent or more between otherwise identical runs. Remediation: keep every value indecimal.Decimalend to end, never quantize the blended rate before using it in the premium multiplication, and quantize only the finalgross_payonce withROUND_HALF_UP.
Frequently Asked Questions
Does the blended rate apply per shift or across the whole workweek?
Across the whole workweek. Every hour the employee worked at every rate, regardless of how many shifts or rate codes those hours were split across, is aggregated into one total-hours figure and one straight-time-compensation figure before the blended rate is computed. An engine that resets the calculation per shift or per rate code will produce a different, understated blended rate and may miss overtime entirely.
How is this different from California's daily double-time rules?
They solve different problems. Calculating Double Overtime for California determines which hours qualify as overtime or double time based on daily and seventh-consecutive-day triggers under state law. This page determines what regular rate overtime is priced against when an employee worked multiple hourly rates in the week, under the federal weighted-average method in 29 CFR § 778.115. A multi-rate California employee needs both stages: California rules select the overtime hours, and this blended-rate method prices them.
Can an employer pay overtime at 1.5x each segment's own rate instead of a blended rate?
Yes, but only by advance agreement. 29 CFR § 778.115 permits the employer and employee to agree in advance that overtime hours will be paid at one-and-a-half times the rate applicable to the type of work actually performed during those overtime hours, rather than the blended weighted-average rate. Absent such an agreement, the weighted-average method in this page is the default and controlling calculation.
What if one of the rates already reflects a shift differential or bonus?
A shift differential paid as a distinct per-hour rate is simply one more segment in the weighted average computed here. A non-discretionary bonus that is not tied to a specific block of hours, however, is apportioned across all hours worked in the period and folded into the rate differently — that calculation is covered in Weighted-Average Regular Rate with Nondiscretionary Bonuses, and the two methods can apply together when an employee has both multiple hourly rates and a non-discretionary bonus in the same week.
Related
- Overtime Calculation Engines — the parent topic that resolves thresholds and jurisdiction before this blended-rate stage prices the premium.
- Weighted-Average Regular Rate with Nondiscretionary Bonuses — the companion § 778.208 method for folding bonuses into the regular rate, which can combine with multiple rate segments in the same week.
- Calculating Double Overtime for California — the jurisdictional daily-threshold engine that determines which hours are overtime before this page’s blended rate prices them.
- Federal Withholding Percentage-Method Lookups — the withholding stage that consumes the Decimal gross this engine produces.