Automating the 10% De Minimis Indirect Cost Rate

Implement the 2 CFR 200.414(f) de minimis indirect rate in Python: election eligibility, a 200.1 Modified Total Direct Cost base with the $50,000 subaward cap, exact 10% Decimal math, and an auditable recovery record.

This guide is part of the Indirect Cost Rate Automation section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you compute the 10% de minimis indirect recovery that a recipient without a negotiated rate is entitled to under 2 CFR §200.414(f) — deterministically, in exact money math, with a record an auditor can replay line by line?

The de minimis rate looks trivial: “multiply your direct costs by ten percent.” It is not. Applying 10% to total direct costs instead of the Modified Total Direct Cost (MTDC) base defined in 2 CFR §200.1 overstates recovery on every award that carries equipment, tuition, or a large subaward — and that overstatement is a disallowed cost on audit. This guide builds a calculator that gates on election eligibility, constructs the MTDC base with every statutory exclusion, applies exactly 10% in Decimal, and emits an immutable recovery record.

When to Use This Approach

Reach for the de minimis rate when all three conditions hold:

  • The recipient has never held a negotiated indirect cost rate agreement (NICRA). Section 200.414(f) offers the de minimis rate to any non-federal entity that does not have a current negotiated rate, with one narrow exception for certain federal agencies and pass-through entities. The 2024 revision to Part 200 removed the old “never received” ceiling and raised the de minimis rate itself to 10% of MTDC, so a broader set of recipients can now elect it. If a NICRA exists, you must apply that rate instead — the mechanics of which belong to the sibling guide, Applying a Negotiated Indirect Cost Rate Agreement in Python. The eligibility gate in Step 2 is what enforces that fork.
  • The election is recorded, not assumed. The de minimis rate is an election the recipient makes and must use consistently across its federal awards. A calculation that cannot point to a dated, documented election is not defensible, so this code writes the election into the same record as the arithmetic.
  • The base is Modified Total Direct Cost, not total direct cost. The 10% multiplier is meaningless without the §200.1 base. Equipment, capital expenditures, rental costs, tuition remission, scholarships and fellowships, participant support costs, and the portion of every subaward above $50,000 are excluded. Skipping the exclusion filter is the single most common way automated indirect recovery goes wrong.

Rate negotiation, cost-allocation-plan authoring, and posting the recovered amount into the general ledger are explicitly out of scope here. Negotiated-rate mechanics live in the sibling guide above; how the recovery figure then maps onto a Form 990 functional-expense line belongs to IRS 990 Data Schema Mapping. This stage does one thing: turn a validated cost ledger into a single, auditable indirect-recovery number.

De minimis indirect recovery flow with a no-NICRA eligibility gate An eligibility gate asks whether the recipient holds a negotiated indirect cost rate agreement. If it does, the flow branches down to the negotiated-rate path and stops here. If it does not, the recipient records the de minimis election and the direct-cost ledger enters an MTDC exclusion filter that removes equipment and capital, rental, tuition remission, scholarships, participant support, and each subaward amount over fifty thousand dollars. The surviving amount forms the MTDC base, which is multiplied by exactly ten percent to produce the documented indirect recovery figure. Eligibility gate, then base, then exactly 10% A recipient without a negotiated rate elects the de minimis rate; the multiplier only ever meets the MTDC base. no NICRA has NICRA rate agreement? Direct-cost ledger election recorded MTDC exclusion filter §200.1 · subaward > $50k MTDC base Decimal, cents × 10% §200.414(f) Indirect recovery documented + auditable Negotiated-rate path apply the NICRA instead

Step-by-Step Implementation

The reference implementation targets Python 3.10+ and depends only on the standard library. Money never touches a binary float: all amounts are decimal.Decimal, and every result is quantized to cents with ROUND_HALF_UP, the convention federal cost accounting expects.

text
# No third-party packages required.
# Pin the interpreter for reproducibility:
#   python == 3.10.14

One contract to internalize before writing any code: the de minimis rate is a fixed statutory constant, not a tunable. It is exactly 10% (Decimal("0.10")) of the MTDC base under 2 CFR §200.414(f). The only variables are eligibility and the composition of the base — never the rate itself.

Step 1: Configure the audit logger and the de minimis contract

Every recovery figure must be replayable from its record, so configure a standard-library logger (never print) and set the decimal context so intermediate arithmetic cannot silently lose precision. Fix the two constants the statute mandates — the 10% rate and the $50,000 per-subaward cap — as module-level Decimal values so no caller can override them.

python
import logging
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP, getcontext
from typing import Dict, List

getcontext().prec = 28  # ample headroom for cent-level nonprofit budgets

AUDIT_LOGGER = logging.getLogger("indirect_cost.de_minimis")
AUDIT_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
AUDIT_LOGGER.addHandler(_handler)

DE_MINIMIS_RATE: Decimal = Decimal("0.10")          # 2 CFR 200.414(f)
SUBAWARD_MTDC_CAP: Decimal = Decimal("50000.00")    # 2 CFR 200.1, per subaward
CENTS = Decimal("0.01")


class EligibilityError(Exception):
    """Raised when the recipient is not entitled to elect the de minimis rate."""


def to_money(value: str) -> Decimal:
    """Coerce an input string to a cent-quantized Decimal; never parse floats."""
    return Decimal(value).quantize(CENTS, rounding=ROUND_HALF_UP)

Parsing money from str rather than float is deliberate: Decimal(0.1) inherits the binary-float error and will drift by fractions of a cent across a large ledger, while Decimal("0.10") is exact. EligibilityError is a distinct, structured exception so callers can branch to the negotiated path instead of catching a generic error.

Step 2: Confirm eligibility and record the election

The de minimis rate is available only to a recipient that does not currently hold a negotiated rate agreement. Gate on that precondition explicitly, and — because the rate is an election the recipient must document and apply consistently — write the election date and the deciding facts into the record at the same moment you validate them.

python
@dataclass
class DeMinimisElection:
    """A dated, documented election to use the 2 CFR 200.414(f) de minimis rate."""

    recipient_ein: str
    has_negotiated_rate: bool
    election_date: str  # ISO-8601, e.g. "2026-07-11"
    determined_by: str

    def validate(self) -> None:
        """Enforce the no-NICRA precondition before any arithmetic runs."""
        if self.has_negotiated_rate:
            AUDIT_LOGGER.error(
                "EIN %s holds a negotiated rate; de minimis election is invalid.",
                self.recipient_ein,
            )
            raise EligibilityError(
                "Recipient holds a current NICRA. Apply the negotiated rate instead."
            )
        AUDIT_LOGGER.info(
            "De minimis election recorded | EIN: %s | date: %s | by: %s",
            self.recipient_ein, self.election_date, self.determined_by,
        )

The has_negotiated_rate flag is the machine form of the eligibility gate in the diagram: True raises EligibilityError and routes the caller to the negotiated-rate guide, while False records the election and lets the base computation proceed. Capturing determined_by and election_date is what makes the election defensible rather than assumed.

Step 3: Build the Modified Total Direct Cost base

The 10% rate applies to MTDC, not total direct cost. Per 2 CFR §200.1, start from total direct costs and subtract equipment and capital expenditures, rental costs, tuition remission, scholarships and fellowships, and participant support costs in full — then subtract only the portion of each subaward that exceeds $50,000. Compute the subaward cap per line item, never on the aggregate, or a single large subaward will mask several small ones.

python
@dataclass
class CostLedger:
    """Direct-cost inputs for one award, all values cent-quantized Decimals."""

    total_direct_costs: Decimal
    equipment_and_capital: Decimal = Decimal("0.00")
    rental_costs: Decimal = Decimal("0.00")
    tuition_remission: Decimal = Decimal("0.00")
    scholarships_fellowships: Decimal = Decimal("0.00")
    participant_support: Decimal = Decimal("0.00")
    subawards: List[Decimal] = field(default_factory=list)


def compute_mtdc_base(ledger: CostLedger) -> Dict[str, Decimal]:
    """Derive the 2 CFR 200.1 MTDC base with a per-subaward $50k cap."""
    excess_subaward = sum(
        (max(amount - SUBAWARD_MTDC_CAP, Decimal("0.00")) for amount in ledger.subawards),
        Decimal("0.00"),
    )
    excluded = (
        ledger.equipment_and_capital
        + ledger.rental_costs
        + ledger.tuition_remission
        + ledger.scholarships_fellowships
        + ledger.participant_support
        + excess_subaward
    )
    mtdc = (ledger.total_direct_costs - excluded).quantize(CENTS, rounding=ROUND_HALF_UP)
    if mtdc < Decimal("0.00"):
        raise ValueError("MTDC base is negative; exclusions exceed total direct costs.")

    AUDIT_LOGGER.info(
        "MTDC base computed | direct: %s | excluded: %s | base: %s",
        ledger.total_direct_costs, excluded, mtdc,
    )
    return {"mtdc_base": mtdc, "total_excluded": excluded, "excess_subaward": excess_subaward}

The max(amount - SUBAWARD_MTDC_CAP, 0) term keeps the first $50,000 of every subaward inside the base and removes only the excess — a $180,000 subaward contributes $50,000 to MTDC and excludes $130,000. The negative-base guard catches malformed ledgers (for example, an exclusion double-counted against a small award) before they become a bogus recovery figure.

Step 4: Apply exactly 10 percent with Decimal math

With a validated election and a clean base, the arithmetic is a single multiplication — but it must be exact and cent-rounded. Multiply the MTDC base by DE_MINIMIS_RATE and quantize once, at the end, with ROUND_HALF_UP. Rounding intermediate products is how off-by-a-cent discrepancies creep into an audit.

python
def apply_de_minimis_rate(mtdc_base: Decimal) -> Decimal:
    """Multiply the MTDC base by the fixed 10% de minimis rate, rounded to cents."""
    if mtdc_base < Decimal("0.00"):
        raise ValueError("Cannot apply a rate to a negative MTDC base.")
    recovery = (mtdc_base * DE_MINIMIS_RATE).quantize(CENTS, rounding=ROUND_HALF_UP)
    AUDIT_LOGGER.info(
        "De minimis rate applied | base: %s | rate: %s | recovery: %s",
        mtdc_base, DE_MINIMIS_RATE, recovery,
    )
    return recovery

Passing DE_MINIMIS_RATE by reference rather than hardcoding * Decimal("0.10") at the call site means the statutory rate lives in exactly one place; if a future Part 200 revision changes it, one constant changes and every recovery recomputes consistently.

Step 5: Emit the documented, auditable recovery record

The deliverable is not a number — it is a record that ties the recovery to the election, the base, and the exclusions that produced it. Assemble an immutable dictionary carrying the citation, the election, every intermediate amount, and the final figure, then log it so the trail is replayable.

python
def build_recovery_record(
    election: DeMinimisElection, ledger: CostLedger
) -> Dict[str, object]:
    """Produce the full, auditable de minimis recovery record for one award."""
    election.validate()  # raises EligibilityError if a NICRA exists
    base = compute_mtdc_base(ledger)
    recovery = apply_de_minimis_rate(base["mtdc_base"])

    record: Dict[str, object] = {
        "authority": "2 CFR 200.414(f)",
        "base_authority": "2 CFR 200.1 (MTDC)",
        "recipient_ein": election.recipient_ein,
        "election_date": election.election_date,
        "total_direct_costs": str(ledger.total_direct_costs),
        "total_excluded": str(base["total_excluded"]),
        "mtdc_base": str(base["mtdc_base"]),
        "rate_applied": str(DE_MINIMIS_RATE),
        "indirect_recovery": str(recovery),
    }
    AUDIT_LOGGER.info(
        "Recovery record complete | EIN: %s | base: %s | recovery: %s",
        election.recipient_ein, base["mtdc_base"], recovery,
    )
    return record

Serializing every Decimal to str keeps the record exact through JSON and any write-once store, where float serialization would reintroduce the very rounding error the calculation avoided. This record is the handoff artifact that IRS 990 Data Schema Mapping consumes when the recovered indirect amount is placed onto a functional-expense line.

Verification

Confirm the calculator behaves deterministically with four checks:

  1. The eligibility gate refuses a NICRA holder. Construct a DeMinimisElection with has_negotiated_rate=True and assert build_recovery_record raises EligibilityError — proof the code defers to the negotiated path rather than computing an unentitled recovery.
  2. The subaward cap applies per line item. Feed two subawards of Decimal("180000.00") and assert excess_subaward equals Decimal("260000.00") (each contributes $130,000 of excess), not a single-award cap on the sum.
  3. The rate is exactly 10% of MTDC. For a ledger whose exclusions leave an MTDC base of Decimal("742350.00"), assert indirect_recovery equals Decimal("74235.00") — and that the result is quantized to two decimal places.
  4. Money never becomes a float. Assert every monetary field in the record parses back with Decimal(...) without loss, and that no intermediate uses float.

A compliant run emits one INFO line per stage — election, base, rate, and record — each carrying the amounts that produced the next. Ship those logs and the record to a write-once tier so the recovery figure remains reproducible for the retention period federal awards require.

python
election = DeMinimisElection(
    recipient_ein="12-3456789", has_negotiated_rate=False,
    election_date="2026-07-11", determined_by="grants.controller",
)
ledger = CostLedger(
    total_direct_costs=to_money("900000.00"),
    equipment_and_capital=to_money("40000.00"),
    participant_support=to_money("17650.00"),
    subawards=[to_money("150000.00"), to_money("20000.00")],
)
record = build_recovery_record(election, ledger)
assert record["mtdc_base"] == "742350.00"
assert record["indirect_recovery"] == "74235.00"
assert record["rate_applied"] == "0.10"

Common Errors & Fixes

Error Cause Fix
Recovery overstated on every award 10% applied to total direct costs, not the MTDC base Run compute_mtdc_base first; apply the rate only to mtdc_base, never to total_direct_costs.
Off-by-a-cent vs. the auditor’s figure Money parsed or stored as float, or intermediates rounded Parse with Decimal("...") via to_money, keep full precision, and quantize once at the end with ROUND_HALF_UP.
One large subaward masks the cap $50,000 cap applied to the aggregate of all subawards Cap max(amount - 50000, 0) per subaward line item and sum the excess, never on the total.
EligibilityError on a valid recipient has_negotiated_rate left True by default or stale Set the flag from the current NICRA status; if a NICRA exists, use Applying a Negotiated Indirect Cost Rate Agreement in Python instead.
ValueError: MTDC base is negative Exclusions double-counted or exceed total direct costs Reconcile the ledger so each exclusion appears once and is a subset of total_direct_costs.