Division by zero in a financial model is a value, not an exception
A gross margin line divides profit by revenue, and revenue is legitimately zero in some periods: a pre-launch month, a seasonal gap, a segment that opened in March. There is no answer to that division. If the evaluator raises there, a quiet month turns into a crash three layers away from the formula behind it, somewhere inside a loop over twelve periods, inside a renderer, or inside the code that runs the checks. We ran into this while building finmodel, our library for declaring financial statements as data. What we settled on is that the non-answer is a value: it carries the reason it has none, it propagates the moment either operand holds it, and every consumer downstream has to know that it exists.
Three bad answers before the good one#
Raising is the default, and of the three it is the worst. The formula gross_profit / total_revenue is correct and the period is simply empty. An exception moves the failure out of the data and into the control flow of whoever happened to be iterating, and the traceback names the evaluator instead of the line.
Returning Decimal(0) looks harmless and is not, because a margin of zero percent is a claim. Someone reads the table, sees 0.00, and walks away believing the business sold at exactly cost. Nothing in the output tells that apart from a month with no sales at all.
Returning None propagates by accident instead of by design. It survives for as long as nobody does arithmetic on it, then fails with a TypeError far away from the division, and prints as a blank cell.
Spreadsheets get this part right, and they really do get it right: #DIV/0! is a value. It sits in the cell, it travels into every cell that references it, and you can see that it is not a number. We wanted that same property in a typed Python evaluator that never calls eval().
NotApplicable#
All of it is one frozen dataclass in src/finmodel/formula.py:
@dataclass(frozen=True)
class NotApplicable:
"""An amount a line cannot have, carrying the reason it has none."""
reason: str = "division by zero"
def __str__(self) -> str:
return f"n/a ({self.reason})"
def __bool__(self) -> bool:
return False
NA = NotApplicable()
Amount = Union[Decimal, NotApplicable]The type alias is the real commitment here. Amount is what every evaluation returns, and it has two inhabitants. From that point on, any function in the library that touches an amount either branches on isinstance(value, NotApplicable) or is wrong.
Returned the moment either operand holds it#
BinaryOp.evaluate looks at its operands before it does anything with them. A NotApplicable on the left comes back before the right side is evaluated at all, and one on the right comes back before any operation runs. Division where the right operand equals zero returns the module constant NA rather than letting Decimal raise. UnaryOp.evaluate passes a NotApplicable straight through, so a negated non-answer is still that non-answer.
from decimal import Decimal
from finmodel import NA, Rounding, parse
margin = parse("gross_profit / total_revenue * 100")
margin.evaluate({"gross_profit": Decimal("-4200.00"), "total_revenue": Decimal(0)})
# NotApplicable(reason='division by zero')
parse("margin_pct - target_margin").evaluate({"margin_pct": NA, "target_margin": Decimal(20)})
# NotApplicable(reason='division by zero')
Rounding(digits=2).apply(NA)
# NotApplicable(reason='division by zero')That last line carries more weight than it reads. Rounding sits between evaluation and storage, since every line states its amounts at a scale, and Rounding.apply leaves a stated non-answer alone instead of calling quantize on something that has no such method. A value that survives arithmetic and then dies in rounding would be no better than None.
The coercion boundary is asymmetric on purpose. to_amount hands back a NotApplicable as it is, and refuses other non-finite values with FormulaError. A NaN or an infinity arriving from a loader is a bug in the input, while a non-answer is a result. One of those should stop the program and the other should not.
Because the value survives coercion, a loader can originate one of its own. The reason is an ordinary field:
from decimal import Decimal
from finmodel import NotApplicable, parse
unfiled = NotApplicable(reason="no actuals filed for the period")
parse("payroll / headcount").evaluate({"payroll": unfiled, "headcount": Decimal(31)})
# NotApplicable(reason='no actuals filed for the period')Once a statement is wired up, propagation is the evaluation order doing its job. Statement.evaluate stores each line's amount under the line's id, and the next line in evaluation_order reads it back out of the scope:
from finmodel import Block, ComputedLine, InputLine, Statement
pnl = Statement(
id="pnl",
label="Profit and loss",
children=[
Block(
id="revenue",
label="Revenue",
children=[InputLine(id="product_sales", label="Product sales")],
),
Block(
id="costs",
label="Costs",
children=[InputLine(id="cogs", label="Cost of goods sold")],
),
ComputedLine(
id="gross_profit",
label="Gross profit",
formula="product_sales - cogs",
),
ComputedLine(
id="gross_margin",
label="Gross margin %",
formula="gross_profit / product_sales * 100",
),
],
)
pnl.evaluation_order
# ('gross_profit', 'gross_margin')In a month where product_sales is zero, gross_profit is an ordinary negative number and gross_margin is a non-answer. Nothing special happened. One line read another's amount out of the scope and found that it was not a number.
A blank cell reads as zero#
The renderer is where a design like this pays off or quietly undoes itself. Row.cells in src/finmodel/render.py writes the string form of the value into the table, so the cell reads n/a (division by zero) instead of sitting empty. The Markdown and CSV writers both sit over that one table model, so they carry the same text.
This is not cosmetic. A statement leaves the process as a file, handed to someone who does not have the code. In a CSV, a blank is indistinguishable from a zero, from a line nobody ever supplied, and from a column the exporter skipped. The reason string is what separates those cases, and it is the only place in the pipeline where the cause of the gap is still known. Write your reasons as something a reader outside the team can act on, because they end up in the deliverable.
Nothing downstream treats it as a number#
A value that travels quietly through arithmetic and printing would be dangerous if it also travelled quietly through the things that assert something. It does not, and the refusals are the other half of the design.
Rolling months into quarters and a year stops rather than smooths. In src/finmodel/periods.py, _combine returns the first NotApplicable it meets instead of a sum. A quarter with one unanswerable month in it is unanswerable, not the sum of the other two. Summing around the gap would mean inventing a number.
Balance identities fail outright. Proof.ok is False when any of opening, movement or closing is a NotApplicable, and PeriodicModel raises IdentityError. opening + movement = closing either holds arithmetically or it has not been demonstrated, and a missing side is the second case.
Checks behave the same way, and this is the piece worth reading in full, from src/finmodel/checks.py:
@property
def difference(self) -> Amount:
if isinstance(self.left, NotApplicable):
return self.left
if isinstance(self.right, NotApplicable):
return self.right
return self.left - self.right
@property
def ok(self) -> bool:
difference = self.difference
if isinstance(difference, NotApplicable):
return False
return abs(difference) <= self.check.toleranceThe tolerance line does not get reached. Check.balances and Check.reconciles can be declared with whatever rounding slack your model needs, and none of it applies here, because tolerance answers how far apart two amounts may be, and one of the amounts does not exist. CheckResult.__str__ reports the verdict as the non-answer's own text, and Checklist.verify raises CheckError naming the period. A reconciliation that could not be computed has not been passed.
Note that none of these decisions lean on __bool__. Falsiness is a convenience for a quick truth test in calling code, while every correctness-bearing branch in the library is an explicit isinstance check, because a Decimal(0) is falsy too and the two must not be confused at a point where it matters.
What it costs to run#
Consumers pay the cost, and the cost is real. Amount is a union, so every function that accepts one has to decide what it does with the second inhabitant. The package ships py.typed, so a checker will point at the branches for you, but code written elsewhere against a plain Decimal will fail at runtime the first time a month goes quiet. There is no way to add a value to a type without making its consumers deal with it, and the alternative was a sentinel that lies.
The second cost is social. Gaps that used to disappear into a 0.00 now show up as text in the statement and as a failed checklist. A model that previously "passed" a quiet month fails it now. That is the outcome we wanted, and it is still a conversation with whoever reads the output.
What it saves is everything else. No try blocks wrapped around arithmetic, no scan for sentinels before rendering, no rule about which caller is responsible for catching a division error. The value does the travelling, and the places that assert something refuse it on sight: the rollup, the balance proof and the checklist.
A zero in a financial model is a measurement, and an empty cell is the absence of one. Keeping those two distinguishable all the way from the formula to the exported file is probably worth one extra type.