The best bank statement analysis software only runs a Benford's law test on extracted data that already ties to the printed balances, because a single misread digit changes the total and the first-digit counts at the same time. That rule decides the top of ZeraBooks' ranking of bank statement analyzers: proof first, analysis after. Below is a first-digit test in plain Python, with the gate in front of it and an example of what bad extraction does to the result.

We make ZeraBooks and rank it first. To be clear about scope, ZeraBooks doesn't run Benford tests and doesn't detect fraud. It supplies proven rows. The test here is yours to run on whatever rows you have.

What does Benford's law predict?

In large sets of naturally occurring amounts, the first digit d shows up about log10(1 + 1/d) of the time. So 1 leads about 30.1% of amounts and 9 only about 4.6%. A digit far from its expected share is a reason to look closer. It isn't a finding by itself.

Why put a tie-out in front of it?

Because a digit test can't tell a fabricated amount from a misread one. If an extractor reads 7s as 1s, the leading-1 share jumps, and the test reports exactly the kind of pattern you'd go looking for. The tie-out would have caught it first, since the totals stop matching the statement.

Here's the gate, using checking ••4417 for March 2025 from Matter 25-117, a made-up matter:

import math import random from collections import Counter from decimal import Decimal as D # Gate: only statements that tie go into the population. march = {"opening": D("14902.35"), "in": D("8120.00"), "out": D("4781.80"), "closing": D("18240.55")} assert march["opening"] + march["in"] - march["out"] == march["closing"] 

In a real run you'd assert this for every statement in the population, and also confirm no month is missing. In this matter, April for ••4417 was never produced, so any money-out population for that account is short a month before you start.

How do you count first digits?

Work from the Decimal string so cents never round. Strip leading zeros and the decimal point, so 0.18 counts as a leading 1.

def first_digit(amount): s = str(abs(amount)).lstrip("0.") return int(s[0]) if s and s[0].isdigit() else None EXPECTED = {d: math.log10(1 + 1 / d) for d in range(1, 10)} def benford(amounts, floor=D("10")): digits = [first_digit(a) for a in amounts if abs(a) >= floor] digits = [d for d in digits if d] n = len(digits) counts = Counter(digits) chi2, mad = 0.0, 0.0 print("digit actual expected") for d in range(1, 10): actual = counts[d] / n expected = EXPECTED[d] chi2 += n * (actual - expected) ** 2 / expected mad += abs(actual - expected) / 9 print(f"{d:>5} {actual:6.1%} {expected:8.1%}") return n, chi2, mad 

The floor drops tiny amounts. Bank fees and interest under $10 tend to cluster on a few values and pull the test around for no interesting reason.

What does a run look like?

The population below is synthetic, not Matter 25-117 rows: 2,000 amounts spread evenly across four orders of magnitude, which is the kind of set Benford fits. I added one real amount from the matter, check 3285 for $2,450.00 to Harbor Marine Supply, so you can see it counted as a leading 2.

random.seed(25117) amounts = [D(f"{10 ** random.uniform(1, 5):.2f}") for _ in range(2000)] amounts.append(D("2450.00")) # check 3285, Harbor Marine Supply n, chi2, mad = benford(amounts) print(f"n={n} chi-square={chi2:.2f} (8 df, 15.51 at 5%) MAD={mad:.4f}") 
digit actual expected 1 28.2% 30.1% 2 16.5% 17.6% ... 9 3.9% 4.6% n=2001 chi-square=11.13 (8 df, 15.51 at 5%) MAD=0.0079 

Under the 5% critical value, so nothing here stands out.

What does one bad extraction habit do?

Now pretend the extractor reads every 7 as a 1:

def misread(a): return D(str(a).replace("7", "1")) n, chi2, mad = benford([misread(a) for a in amounts]) print(f"n={n} chi-square={chi2:.2f} MAD={mad:.4f}") 
digit actual expected 1 34.6% 30.1% ... 7 0.0% 5.8% ... n=2001 chi-square=137.16 MAD=0.0167 

Chi-square goes from 11.13 to 137.16. Leading 7s vanish and leading 1s pile up. On a real matter, that's a page in someone's report about amounts nobody altered. The tie-out on each statement would have failed long before this ran, because the misread amounts don't add up to the printed closing balances.

When should you skip the test?

  • Small populations. A few hundred rows can wander off the curve by chance.
  • Capped or fixed amounts, such as charges held under a card limit or a set monthly rent.
  • Mixed populations. Test one account's money out, or all card charges, not everything together.
  • Any population with a missing month you haven't accounted for.

Where does ZeraBooks fit?

At the front. Each statement is proven to its printed balances, per account and per cardholder, before the rows reach your workpaper, and a month that doesn't follow the last one is flagged. For firms, it's the same engine ZeraBooks uses to run books automatically, with the output built around your own template. More on ZeraBooks for forensic teams.

Run Benford on rows you'd defend. Otherwise you're testing the extractor.