DEV Community

Cover image for CPF contribution calculation in code: 2 common shortcuts fail half the time
Jeremiah Say
Jeremiah Say

Posted on

CPF contribution calculation in code: 2 common shortcuts fail half the time

Singapore's CPF contribution looks like a one-line calculation. Take the wage, multiply by a rate, done. If you're building payroll, an HR tool or a salary calculator, that line is where the bugs start, because CPF Board's rule has three steps and they're in a particular order.

So I tested it. I took five shortcuts I'd expect to find in real code and ran each one against every wage from S$750.01 to S$8,000, one cent at a time. That's 725,000 wages in each of CPF's five age bands. The right answers use integers only, so floats can't affect them.

Two shortcuts fail on about half of all wages. The one I expected to fail, floating-point, didn't fail at all.

CPF Board's rule

These steps come from the CPF contribution rate table from 1 January 2026:

  1. Work out the total contribution and round it to the nearest dollar. Below 50 cents goes down. 50 cents and above goes up.
  2. Work out the employee's share and round it down to the nearest dollar.
  3. Employer's share = total − employee's share.

The third step is the one people miss. The employer's rate never gets rounded on its own. That share is whatever's left.

For wages above S$750 at age 55 and below, the total rate is 37% and the employee's rate is 20%. Ordinary wages (OW) count only up to the S$8,000 monthly ceiling, which gives the table's maximums of S$2,960 and S$1,600.

Here's a worked example for a S$4,321.50 wage:

Step Sum Result
Total 37% × 4,321.50 = 1,598.955 S$1,599
Employee 20% × 4,321.50 = 864.30 S$864
Employer 1,599 − 864 S$735

The results

Share of wages from S$750.01 to S$8,000 that get the wrong answer, at age 55 and below, on the 2026 rates:

Shortcut Wrong wages Share
Round the employee's share to nearest 362,500 50.00%
Round the employer's share, then add the employee's 361,735 49.89%
Round to cents first, then to dollars 3,618 0.50%
Python's round() on the total 36 0.005%
Use floats, with the right rule 0 0%

The other age bands look much the same. The largest figure for the two big failures is 50.07%, and each error is one dollar. One dollar sounds harmless, but the employee and employer columns won't match what CPF expects.

Failure 1: rounding the employee's share to nearest

employee = round(wage * 0.20)   # wrong
Enter fullscreen mode Exit fullscreen mode

Half of all wages land on or above 50 cents, so half get rounded up, and the employee overpays by a dollar. CPF's page says the cents "should always be dropped" for this share.

Failure 2: giving the employer its own rounding

employer = round(wage * 0.17)   # wrong
employee = floor(wage * 0.20)
total = employer + employee
Enter fullscreen mode Exit fullscreen mode

I found this one described in at least one HR glossary, and it reads sensibly. But 17% plus 20% are rounded separately here, so the total can drift a dollar from 37% of the wage rounded once. It disagreed with CPF's rule on 49.89% of wages.

Failure 3: rounding twice

total = round_half_up(round(wage * 0.37, 2))   # wrong
Enter fullscreen mode Exit fullscreen mode

Money code often rounds to cents at every step. Here that creates a double rounding error. On a wage of S$752.69, 37% is 278.4953. That's below 50 cents, so the total is S$278. Round to cents first and you get 278.50, which rounds up to S$279.

This hits about 1 in 200 wages. Nearly all of those wages have cents in them, so it can pass every test that uses round numbers. At 37%, not one whole-dollar wage trips it.

Failure 4: banker's rounding

Python's built-in round() sends ties to the nearest even number. CPF sends them up.

>>> round(850 * 37 / 100)    # 314.5
314
Enter fullscreen mode Exit fullscreen mode

CPF's answer for S$850 is S$315. This fails rarely but on very normal wages: 36 whole-dollar amounts at 37%, such as S$850 and S$1,050. The worst band is above 60 to 65, where 906 whole-dollar wages fail, because a 25% rate lands on exact 50-cent ties far more often. numpy.round and the default Decimal context behave the same way, so set the rounding mode yourself. JavaScript's Math.round and PHP's round both send these positive ties up.

The one that didn't fail: floats

I expected this to be the headline. Floating-point has burned me before, so I tested wage * 37 / 100 with the correct rule on the 2026 and 2027 rates, and wage * 0.37 on the 2026 rates. Both gave 0 wrong answers across all five bands.

Don't read that as "floats are safe for money". It only covers this range and these rates. Integers still cost you nothing here, and you won't need to rerun the proof when the rates change.

Bonus months: round once

When ordinary and additional wages (AW) land in the same month, CPF's note 4 says to compute the contributions for both, sum them, and then apply the rounding rules. So you round once, on the sum.

total = half_up(ow * 0.37) + half_up(aw * 0.37)   # wrong
Enter fullscreen mode Exit fullscreen mode

I tested 207 million OW and AW pairs at 37%, with AW up to S$2,000 in 7-cent steps, and rounding them apart gave the wrong total on 25.00% of them. With OW of S$751 and AW of S$1.41, the correct total is S$278, and rounding apart gives S$279.

Also note that the OW ceiling caps only the ordinary wage. Additional wages have their own annual ceiling of S$102,000 minus the OW subject to CPF for the year, and a bonus can take you well past S$2,960 in one month.

A small correct version

This covers wages above S$750. Everything is in cents and tenths of a percent, so nothing can drift:

OW_CEILING = 800_000  # S$8,000, in cents

def cpf(ow_cents, aw_cents, total_rate, employee_rate):
    """Rates in tenths of a percent: 370 means 37%. Returns whole dollars."""
    wage = min(ow_cents, OW_CEILING) + aw_cents
    total = (wage * total_rate + 50_000) // 100_000
    employee = wage * employee_rate // 100_000
    return total, employee, total - employee

print(cpf(432_150, 0, 370, 200))  # (1599, 864, 735)
print(cpf(1_000_000, 0, 370, 200))  # (2960, 1600, 1360)
Enter fullscreen mode Exit fullscreen mode

Wages of S$750 and below need CPF's lower bands, which include a phase-in formula, and you'll need to check the AW ceiling before you call this. The live rates and age bands are on our CPF contribution rates page, which reads them from CPF Board.

Rates change, so make them data

Don't hard-code the percentages. The rates for ages above 55 to 65 go up on 1 January 2027: above 55 to 60 moves from 34% to 35.5%, and above 60 to 65 from 25% to 26%. That shifts the failure counts. Banker's rounding in the above-60 band falls from 906 wages to 72, because 26% lands on far fewer exact ties than 25%.

I also ran the formula behind the Numbers.sg CPF calculator through the same sweep, this time including the low bands: every wage from S$0.01 to S$8,000 in all five age bands, 4,000,000 checks on the 2026 rates, with 0 wrong answers.

Limits

  • These are the rates for Singapore Citizens and third-year-onward PRs in the private sector. Other groups have other tables.
  • Each dollar error is a modelled difference against CPF's written rule. I haven't sampled how real payroll products behave.
  • For a real submission, check the result with CPF Board's own CPF contribution calculator.

Numbers.sg reads every figure from the agency that publishes it. The update schedule shows when each one is checked next, and about Numbers.sg covers who runs the site.

AI assistants helped draft this post and write the test. The results come from the script below, run on 7 November 2026.

The full test script (Python, needs numpy)

#!/usr/bin/env python3
"""Test common CPF rounding shortcuts against CPF Board's rule, for every wage from $750.01 to $8,000
in steps of one cent, across the age bands of the rate tables from 1 Jan 2026 and 1 Jan 2027.
The right answers use integers only, so floats can't affect them. Needs numpy."""
import numpy as np

Y2026 = {"55 and below": (37, 20), "above 55 to 60": (34, 18), "above 60 to 65": (25, 12.5),
         "above 65 to 70": (16.5, 7.5), "above 70": (12.5, 5)}
Y2027 = {**Y2026, "above 55 to 60": (35.5, 19), "above 60 to 65": (26, 13)}
W = np.arange(75001, 800001, dtype=np.int64)  # wages in cents
WHOLE = W % 100 == 0


def half_up(cents, pct):
    return (cents * round(pct * 10) + 50_000) // 100_000


def floored(cents, pct):
    return cents * round(pct * 10) // 100_000


def shortcuts(tp, ep):
    T, E, w = half_up(W, tp), floored(W, ep), W / 100
    ft, fe, fr = w * tp / 100, w * ep / 100, w * (tp - ep) / 100
    up = lambda x: np.floor(x + 0.5).astype(np.int64)
    return {
        "floats, otherwise the right rule": (up(ft) != T) | (np.floor(fe).astype(np.int64) != E),
        "banker's rounding on the total (Python round)": np.round(ft).astype(np.int64) != T,
        "round to cents first, then to dollars": up(np.round(ft, 2)) != T,
        "round the employee share to nearest": up(fe) != E,
        "round employer share, add floored employee share": up(fr) + np.floor(fe).astype(np.int64) != T,
    }


def report(name, bands):
    print(f"\n{name}: {len(W):,} wages per band, {WHOLE.sum():,} of them whole dollars")
    res = {b: shortcuts(*r) for b, r in bands.items()}
    for s in next(iter(res.values())):
        print(s)
        for b in bands:
            bad = res[b][s]
            print(f"  {b:16}{bad.sum():9,} wrong ({bad.mean() * 100:6.2f}%)   whole dollars:{bad[WHOLE].sum():7,}")


def bonus_month():
    """OW $751 to $8,000 and AW $0.01 to $2,000 at 37%: round the sum once, or round OW and AW apart."""
    ow = np.arange(751, 8001, dtype=np.int64)[:, None] * 100
    aw = np.arange(1, 200001, 7, dtype=np.int64)[None, :]
    once = half_up(ow + aw, 37)
    apart = half_up(ow, 37) + half_up(aw, 37)
    bad = once != apart
    print(f"\nBonus month: {bad.size:,} OW and AW pairs, {bad.sum():,} wrong ({bad.mean() * 100:.2f}%)")
    i = np.argwhere(bad)[0]
    print("first:", ow[i[0], 0] / 100, aw[0, i[1]] / 100, "once", once[i[0], i[1]], "apart", apart[i[0], i[1]])


report("2026", Y2026)
report("2027", Y2027)
bonus_month()
for w, tp in [(850, 37), (1050, 37)]:
    print(w, "round:", round(w * tp / 100), "CPF:", half_up(np.int64(w * 100), tp))
s = shortcuts(37, 20)["round to cents first, then to dollars"]
print("cents-first example:", W[s][:3] / 100, [W[s][0] / 100 * 0.37])
Enter fullscreen mode Exit fullscreen mode

Top comments (0)