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 float division, 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 rbr_b as total straight-time earnings across every rate divided by total hours worked:

rb=irateihoursiihoursir_b = \frac{\sum_i \text{rate}_i \cdot \text{hours}_i}{\sum_i \text{hours}_i}

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:

OT premium=(Htotal40)×rb×0.5for Htotal>40\text{OT premium} = (\,H_{\text{total}} - 40\,) \times r_b \times 0.5 \quad \text{for } H_{\text{total}} > 40

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.

Blended-rate overtime calculation flow Two rate segments within a single workweek, each contributing hours times rate to a straight-time total, converge into a weighted-average blended regular rate per 29 CFR 778.115. That blended rate then determines the half-time overtime premium applied to hours worked beyond 40 in the week, and the straight-time total and the premium are summed into a single Decimal gross pay figure, quantized once. Rate segment A hours_A × rate_A = comp_A Rate segment B hours_B × rate_B = comp_B Weighted-average blended rate r_b = (comp_A + comp_B) ÷ (hours_A + hours_B) 29 CFR § 778.115 — whole-week average, unrounded Half-time overtime premium (hours over 40) × r_b × 0.5 straight time already paid on every hour Decimal gross pay straight-time comp + OT premium — quantize once, ROUND_HALF_UP No hour is priced against a single raw rate — every hour shares the same blended rate for OT purposes.
Two rate segments in one workweek blend into a single regular rate, which then prices the half-time overtime premium on hours over 40.

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:

  1. 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.
  2. 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:

  1. Two-rate blended value. Feed 25 hours at $18.00 and 20 hours at $24.00 and assert blended_rate == Decimal("930.00") / Decimal("45") exactly — the division must not be pre-rounded before the assertion.
  2. 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.
  3. Boundary at exactly 40 hours. Feed 22 hours at $18.00 and 18 hours at $24.00 (total 40) and assert ot_hours == Decimal("0") and gross_pay == Decimal("828.00") — the straight-time total with no premium added, even though the blended rate ($20.70) is well-defined.
  4. Decimal determinism. Run the same two-rate input three times and assert byte-identical gross_pay. Any run-to-run drift means a float entered the rate or premium path.
  5. 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_rate from 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_code bucket, 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: aggregate hours across 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_hours with binary float introduces rounding error that compounds when the result is later multiplied by overtime hours and 0.5, producing gross pay that differs by a cent or more between otherwise identical runs. Remediation: keep every value in decimal.Decimal end to end, never quantize the blended rate before using it in the premium multiplication, and quantize only the final gross_pay once with ROUND_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.