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.
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.
# 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.
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.
@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.
@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.
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.
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:
- The eligibility gate refuses a NICRA holder. Construct a
DeMinimisElectionwithhas_negotiated_rate=Trueand assertbuild_recovery_recordraisesEligibilityError— proof the code defers to the negotiated path rather than computing an unentitled recovery. - The subaward cap applies per line item. Feed two subawards of
Decimal("180000.00")and assertexcess_subawardequalsDecimal("260000.00")(each contributes $130,000 of excess), not a single-award cap on the sum. - The rate is exactly 10% of MTDC. For a ledger whose exclusions leave an MTDC base of
Decimal("742350.00"), assertindirect_recoveryequalsDecimal("74235.00")— and that the result is quantized to two decimal places. - Money never becomes a float. Assert every monetary field in the record parses back with
Decimal(...)without loss, and that no intermediate usesfloat.
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.
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. |
Related
- Parent section: Indirect Cost Rate Automation
- When a negotiated rate exists instead: Applying a Negotiated Indirect Cost Rate Agreement in Python
- Where the recovered amount maps onto a filing: IRS 990 Data Schema Mapping