A source sends the amount 12.345. Your destination stores integer cents. What should happen?
Rounding is one possible answer. Rejection is another. The important part is choosing the rule before converting the value.
This Python expression returns 12.34:
Decimal("12.345").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
That is correct for the requested rounding operation. It is a bad validation rule if the source contract promises amounts with at most two decimal places. In that contract, the third decimal is something to investigate, not silently discard.
Separate three decisions
Before writing a parser, decide:
- Which input spellings are allowed?
- What scale and range does the field use?
- Is excess precision rejected or rounded under an explicit business rule?
A Decimal constructor handles numeric representation. It does not define your entire input contract. For example, scientific notation can be a valid decimal number while still being forbidden in a particular source field.
A deliberately narrow parser
Here is one exercise contract:
- Input must be a string containing ASCII digits.
- An optional leading minus sign is allowed; a leading plus is not.
- There are one to nine digits before the decimal point, with no extra leading zeros.
- The fractional part, if present, contains one or two digits.
- The field uses exactly 100 minor units per major unit.
- Whitespace, separators, scientific notation, non-finite values, and floats are rejected.
- Negative zero normalizes to integer zero.
These are teaching choices, not a universal money format. In particular, this function does not infer scale from a currency code. A different currency or upstream schema needs its own explicit scale and validation contract.
The parser uses string validation and integer arithmetic. Decimal rounding is shown separately so it cannot quietly turn an invalid source amount into an accepted one.
Save the complete script below as strict_amount_demo.py and run it with Python. It uses the standard library. I checked it on Python 3.14.3.
import re
from decimal import Decimal, ROUND_HALF_EVEN
def parse_cents(text):
"""One exercise contract: signed ASCII amounts, at most two decimals."""
if not isinstance(text, str):
raise ValueError("amount must arrive as text")
match = re.fullmatch(r"(-?)(0|[1-9][0-9]{0,8})(?:\.([0-9]{1,2}))?", text)
if match is None:
raise ValueError("invalid amount format or range")
sign, whole, fraction = match.groups()
minor = int(whole) * 100 + int((fraction or "").ljust(2, "0"))
return -minor if sign else minor
accepted = {
"0": 0,
"0.1": 10,
"-0.01": -1,
"12.30": 1230,
"-0.00": 0,
"999999999.99": 99999999999,
}
for raw, expected in accepted.items():
assert parse_cents(raw) == expected
rejected = [
"12.345", "1e2", "1,000.00", " 12.30", "12.30 ",
"NaN", "Infinity", "+12.30", "01.00", "1000000000.00",
"12.30\n", "١٢.٣٠", 12.3, None,
]
for raw in rejected:
try:
parse_cents(raw)
except ValueError:
pass
else:
raise AssertionError(f"accepted {raw!r}")
rounded = Decimal("12.345").quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
assert rounded == Decimal("12.34")
print(f"accepted cases: {len(accepted)}; rejected cases: {len(rejected)}")
print("explicit rounding of 12.345:", rounded)
Output:
accepted cases: 6; rejected cases: 14
explicit rounding of 12.345: 12.34
Why the small details matter
re.fullmatch makes the entire input follow the grammar. A trailing newline does not slip through. The explicit [0-9] character class also makes the ASCII restriction visible.
Right-padding the fraction distinguishes 0.1 from 0.01: the first becomes 10 cents, the second one cent. The sign is applied to the completed integer amount.
The nine-digit bound is part of this example's contract. It bounds the work done by the integer conversion, but it does not replace a request-size limit at the transport boundary. The returned integer also still needs to fit whatever storage or downstream field you choose.
If your application needs to display a signed zero or preserve the original spelling, retain that information separately; converting to integer minor units intentionally loses it.
When rounding is the right operation
Calculated values such as allocations or conversions may genuinely require rounding. Put that operation at a named boundary, choose the rounding mode explicitly, and define what happens to any remainder. Do not make the ingestion parser guess the policy.
Python's decimal documentation covers exact decimal representation, rounding modes, and contexts. In particular, constructing a Decimal from a float preserves that float's value; it does not recover an original decimal string that was already lost upstream.
Extend the exercise
I maintain a free fintech data engineering roadmap. Its E01 exact-money exercise asks you to convert strings using explicit currency scales and reject floats and excess precision. It has a different contract from the narrow parser above, so do not submit this function unchanged and assume it solves E01.
A useful next step is to write down your own source grammar, then add one accepted and one rejected boundary case before implementing it. For this example, changing the maximum whole-part length or permitting +12.30 should be a conscious contract change with a matching test.
Top comments (0)