news 2026/10/8 8:25:49

用了 Decimal 金额还是算错:Python 金额计算的八个坑(舍入、精度、JSON、入库)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用了 Decimal 金额还是算错:Python 金额计算的八个坑(舍入、精度、JSON、入库)

前阵子对账,一笔订单 3 件、单价 19.99,脚本算出来的应收是 59.96999999999999531041794398。写这段代码的人其实已经"用了 Decimal",问题出在Decimal(19.99)少了一对引号。

金额计算换成 Decimal 只是第一步,后面还有舍入、精度、序列化、入库一串坑。下面八条都在 Python 3.14.6 上跑过,输出贴在代码下面。

1. float 的问题不只是 0.1 + 0.2

print(0.1 + 0.2, 0.1 + 0.2 == 0.3) print(sum([0.1] * 10), 19.99 * 3)
0.30000000000000004 False 1.0 59.97

第二行可能和你的预期不一样:sum([0.1] * 10)在新版本里是1.0,因为 Python 3.12 起sum()对浮点数做了补偿求和,误差被压下去了;19.99 * 3打印出来也"正好"是 59.97。

这恰恰是 float 难缠的地方:大部分时候看起来是对的,于是测试都能过,直到某个组合刚好暴露出来。金额不要用 float,这条没有例外。

2. Decimal(19.99) 和 Decimal("19.99") 是两个数

print(Decimal(0.1)) print(Decimal("0.1")) print(Decimal(19.99) * 3, Decimal("19.99") * 3)
0.1000000000000000055511151231257827021181583404541015625 0.1 59.96999999999999531041794398 59.97

Decimal(0.1)会把那个 float 的二进制误差原封不动地变成十进制,精确到 55 位小数。开头那个对账问题就是这么来的。

规则:Decimal 只从字符串或整数构造。数据从 JSON、数据库里出来已经是 float 的话,先str()一下再转,不过这只是补救,更好的做法是源头就别让它变成 float(见第 6、8 条)。

3. round() 和 quantize() 默认都是"银行家舍入"

print(round(2.5), round(3.5), round(0.125, 2), round(2.675, 2)) print(Decimal("2.675").quantize(Decimal("0.01"))) print(Decimal("0.125").quantize(Decimal("0.01")), Decimal("0.125").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
2 4 0.12 2.67 2.68 0.12 0.13

几个现象要分开看:

  • round(2.5)是 2,round(3.5)是 4:Python 的round用的是"四舍六入五成双"(ROUND_HALF_EVEN),恰好是 5 的时候向偶数靠。
  • round(2.675, 2)是 2.67:这次不是舍入规则的问题,是 2.675 这个 float 实际存的是 2.67499999...
  • Decimal 的quantize默认也是 HALF_EVEN,所以0.125变成0.12。

财务和电商场景大多要的是我们小学学的四舍五入,必须显式传rounding=ROUND_HALF_UP。我见过最隐蔽的一个 bug,就是某处quantize忘了传这个参数,只有在千分位恰好是 5、而且百分位是偶数时才会差一分钱。

4. 默认精度 28 位,是有效数字不是小数位

print(getcontext().prec) print(Decimal(1) / Decimal(3)) big = Decimal("12345678901234567890123456789.01") print(big + Decimal("0.01"))
28 0.3333333333333333333333333333 1.234567890123456789012345679E+28

28 是总的有效数字位数。整数部分已经 29 位,加一分钱之后结果被舍入到 28 位有效数字,小数部分整个没了,还变成了科学计数法。

日常金额到不了这个量级,但要注意两种情况:一是汇率、利率这种多位小数连乘,中间结果的位数会涨得很快;二是有人在某个地方getcontext().prec = 6想"控制小数位",结果是全局生效、而且控制的是有效数字。控制小数位请用quantize,需要改精度就用localcontext()限定范围。

5. 分摊:先舍入再求和,和先求和再舍入,差一分钱

q = Decimal("0.01") items = [Decimal("33.333"), Decimal("33.333"), Decimal("33.334")] print(sum(x.quantize(q, ROUND_HALF_UP) for x in items), sum(items).quantize(q, ROUND_HALF_UP)) total, n = Decimal("100.00"), 3 each = (total / n).quantize(q, ROUND_HALF_UP) print("平均分", each, each * n, "差", total - each * n) parts = [each] * (n - 1) + [total - each * (n - 1)] print("尾差给最后一个", parts, sum(parts))
99.99 100.00 平均分 33.33 99.99 差 0.01 尾差给最后一个 [Decimal('33.33'), Decimal('33.33'), Decimal('33.34')] 100.00

100 块分给 3 个人,每人 33.33,加起来 99.99,凭空少了一分。优惠券分摊到商品、运费分摊到子订单都会碰到。

常见做法是"前 n-1 个按比例算并舍入,最后一个用总额减",保证加总严格等于原值。哪个子项承担尾差要提前和业务说好,比如给金额最大的那项,否则退款时又会对不上。

6. json 不认识 Decimal

json.dumps({"amount": Decimal("19.99")}) # TypeError: Object of type Decimal is not JSON serializable print(json.dumps({"amount": str(Decimal("19.99"))})) print(json.loads('{"amount": 19.99}', parse_float=Decimal)) print(json.loads('{"amount": 0.1}')["amount"] + 0.2)
{"amount": "19.99"} {'amount': Decimal('19.99')} 0.30000000000000004

输出方向:要么转成字符串,要么转成"分"为单位的整数。我更推荐后者,前端 JavaScript 拿到"19.99"字符串后很可能又parseFloat回去了,整数分就没有这个问题。

输入方向:json.loads默认把小数解析成 float,误差在解析那一刻就产生了。传parse_float=Decimal可以让它直接构造 Decimal(内部用的是原始字符串,不经过 float)。

7. 1.0 和 1.00 相等,但打印出来不一样

print(Decimal("1.0") == Decimal("1.00"), str(Decimal("1.0")), str(Decimal("1.00"))) print(len({Decimal("1.0"), Decimal("1.00")})) print(Decimal("1.10") == 1.1, Decimal("1.5") == 1.5)
True 1.0 1.00 1 False True

Decimal 会保留尾随的 0,比较时相等、放进集合也算同一个,但str()不同。如果你拿字符串去做签名、做缓存 key、或者和第三方对账,1.0和1.00就是两个值。对外输出前统一quantize(Decimal("0.01"))。

第三行更值得注意:Decimal("1.10") == 1.1是 False,Decimal("1.5") == 1.5是 True。Decimal 和 float 比较时按精确值比,1.5 在二进制里能精确表示,1.1 不能。代码里混着比,结果取决于具体数值,等于埋雷。

8. 入库:sqlite3 直接拒绝,NUMERIC 列存回来是 float

con = sqlite3.connect(":memory:") con.execute("create table t(amount numeric)") con.execute("insert into t values (?)", (Decimal("19.99"),)) # ProgrammingError: Error binding parameter 1: type 'decimal.Decimal' is not supported con.execute("insert into t values (?)", (str(Decimal("19.99")),)) row = con.execute("select amount, typeof(amount) from t").fetchone() print(row, type(row[0]))
(19.99, 'real') <class 'float'>

sqlite3 默认不认 Decimal;转成字符串存进 NUMERIC 列后,SQLite 按亲和性把它存成了 REAL,读出来又是 float,前面的功夫白做。

在 SQLite 里存金额,最省心的是整数分。MySQL、PostgreSQL 有真正的 DECIMAL 类型,驱动一般会返回 Decimal,但也要确认一下 ORM 的字段定义,有些框架默认会转成 float。

小结

坑做法
float 算金额不用
Decimal(0.1)只从字符串或整数构造
round / quantize显式 ROUND_HALF_UP
prec=28是有效数字,小数位用 quantize
分摊差一分尾差给指定的一项
JSON输出转整数分或字符串,输入用 parse_float
1.0 vs 1.00对外输出前统一 quantize
SQLite存整数分

说个局限:这篇只讲了 Python 这一侧。真实系统里金额要经过前端、接口、数据库好几道,任何一道转成了 float 都会前功尽弃,所以最根本的还是全链路约定一个表示法(整数分最不容易出错)。

我平时在 forxi.cn 做一些在线小工具,单位换算、金额计算这类场景绕不开精度问题,这些坑基本都是反复碰到之后整理的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 8:23:11

86% 的资金、2% 的追踪:AI 商业模式从席位订阅到结果计费的重构

【摘要】2026 年上半年美国 VC 向 AI 公司投入约 3559 亿美元&#xff0c;占投资总额的 86%&#xff0c;交易笔数占比仅 43.2%&#xff1b;69% 的标普 500 公司已有正式运行的 AI 项目&#xff0c;持续追踪指标的仅 2%。这组落差指向 AI 商业模式的重构主线&#xff1a;资金向头…

作者头像 李华
网站建设 2026/10/8 8:22:59

数据库逻辑结构设计

逻辑结构设计核心任务是将概念模型&#xff08;通常是E-R图&#xff09;转换为具体的数据库管理系统&#xff08;DBMS&#xff09;支持的数据模型&#xff08;如关系模型&#xff09;&#xff0c;并对其进行优化。一、 主要任务与目标转换模型&#xff1a;将概念模型&#xff0…

作者头像 李华
网站建设 2026/10/8 8:22:35

Kubernetes集群——Ingress篇

目录 一.认识Ingress——控制器 1.1Ingress介绍 1.2Ingress Controller介绍 二.Ingress-nginx 2.1简单介绍 2.2部署Ingress-nginx 2.3修改Ingress-nginx的Service类型 三.基于DaemonetHostnetwork部署Ingress-nginx&#xff08;可选&#xff09; 四.基于虚拟机名称访问…

作者头像 李华
网站建设 2026/10/8 8:22:32

10-Pandas导出Excel

数据分析完了总得有个产出——给老板看、给自己留档、做报告&#xff0c;都需要一份规整的报表。这一篇教你把分析结果导出成专业的 Excel 文件&#xff0c;把前面学的读取、清洗、分组、筛选全部串起来&#xff0c;最后落地到一份漂漂亮亮的报表上。先补上CSV这课 前面用过 to…

作者头像 李华
网站建设 2026/10/8 8:17:56

购房的术语大全的庖丁解牛

总纲&#xff1a;购房&#xff0c;是人生账本里规模最大、周期最长的一次人生置换。拿出大额金钱资产、未来数十年的时间心力&#xff0c;换取居住使用权、不动产资产&#xff1b;同时背负长期刚性负债。它不只是一次商品买卖&#xff0c;会直接改写人生账本收支、改变35岁窗口…

作者头像 李华