`SUM()` over `DECIMAL` silently returns a wrapped negative number, including under `sql_mode = 'ERROR_IF_OVERFLOW'`
Steps to reproduce the behavior (Required)
CREATE DATABASE IF NOT EXISTS sr_ovf_demo;
CREATE TABLE sr_ovf_demo.two (id INT, a DECIMAL(38,0))
DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES('replication_num'='1');
INSERT INTO sr_ovf_demo.two VALUES
(1, 99999999999999999999999999999999999999),
(2, 99999999999999999999999999999999999999);
-- the exact sum is 199999999999999999999999999999999999998
-- 1. default sql_mode: binary arithmetic returns NULL, as documented
SELECT a + a AS plus, a * 2 AS times FROM sr_ovf_demo.two LIMIT 1;
-- NULL | NULL
-- 2. the same overflow through SUM: a wrapped negative number, no error
SELECT SUM(a) FROM sr_ovf_demo.two;
-- -140282366920938463463374607431768211458
-- 3. strict mode: binary arithmetic errors, as documented ...
SET sql_mode = 'ERROR_IF_OVERFLOW';
SELECT a + a FROM sr_ovf_demo.two LIMIT 1;
-- ERROR 1064 (HY000): Expr evaluate meet error: The 'add' operation involving
-- decimal values overflows: BE:10001
-- 4. ... and SUM still returns the same wrong number
SELECT SUM(a) FROM sr_ovf_demo.two;
-- -140282366920938463463374607431768211458
-- 5. one cast, same rows, same query: the exact answer, via a 256-bit accumulator
SELECT SUM(CAST(a AS DECIMAL(50,0))) FROM sr_ovf_demo.two;
-- 199999999999999999999999999999999999998repro.py in the linked report runs the same thing plus the row-count threshold below.
Expected behavior (Required)
The behaviour DECIMAL.md documents for arithmetic overflow, applied to the aggregate as it
already is to a + a and a * 2:
- default
sql_mode→NULL sql_mode = 'ERROR_IF_OVERFLOW'→ an error
or, since step 5 shows the engine can compute the exact value, widening the accumulator. What should not happen is a wrong number with no signal, in either mode.
docs/en/sql-reference/data-types/numeric/DECIMAL.md (on main today) says:
You can set the system variable
sql_modetoERROR_IF_OVERFLOWto allow the system to return an error instead of NULL in the case of arithmetic overflow.
It draws no distinction between aggregates and binary arithmetic.
Real behavior (Required)
SUM() accumulates into an int128 that is never checked, so it wraps. The returned value
is exactly the true sum less 2^128.
The documented strict mode does not reach it. This is the sharpest part: a user who sets
ERROR_IF_OVERFLOW — the documented way to say "never hand me a silently wrong number" —
gets the same wrong number.
sql_mode |
expression | result |
|---|---|---|
| default | a + a |
NULL — as documented |
| default | a * 2 |
NULL — as documented |
| default | SUM(a) |
-140282366920938463463374607431768211458 |
ERROR_IF_OVERFLOW |
a + a |
error — as documented |
ERROR_IF_OVERFLOW |
a * 2 |
error — as documented |
ERROR_IF_OVERFLOW |
SUM(a) |
-140282366920938463463374607431768211458 |
It needs no extreme values — just enough rows
With an ordinary 34-digit value (10^33), the running total leaves int128 after 170 141
rows. Same column, same query, one row more:
| rows | exact | returned | |
|---|---|---|---|
| 170 140 | 170140000000000000000000000000000000000 | same | ok |
| 170 141 | 170141000000000000000000000000000000000 | same | ok |
| 170 142 | 170142000000000000000000000000000000000 | -170140366920938463463374607431768211456 | wrong |
| 170 143 | 170143000000000000000000000000000000000 | -170139366920938463463374607431768211456 | wrong |
The crossing point is int128_max / value, so it moves with the data and is reached by row
count alone. A table that has been correct for months starts returning a negative total on
the row that crosses it, and every query on that column is wrong from then on.
Every accumulating path, and the storage engine
On the same two rows of 10^38 - 1:
| path | returned | |
|---|---|---|
SUM(a) |
-140282366920938463463374607431768211458 | wrong |
SUM(a) ... GROUP BY g |
-140282366920938463463374607431768211458 | wrong |
SUM(a) OVER () |
-140282366920938463463374607431768211458 | wrong |
SUM(a) + 0 |
-140282366920938463463374607431768211458 | wrong |
AVG(a) |
29844659590457395982302900264509.685184 | wrong |
AGGREGATE KEY table, a DECIMAL(38,0) SUM |
-140282366920938463463374607431768211458 | wrong, and persisted |
SUM(DISTINCT a) |
99999999999999999999999999999999999999 | correct |
MAX(a) |
99999999999999999999999999999999999999 | correct |
Two of these deserve separating out.
AVG breaks an invariant that needs no arithmetic to check. The average of two identical
values is that value. Both inputs are 10^38 - 1; the answer comes back as about
2.98 × 10^31, seven orders of magnitude out, because AVG divides the already-wrapped sum.
The AGGREGATE KEY case is not a query-time artifact. That table pre-aggregates on
write, so the wrapped value is what the storage engine persists:
CREATE TABLE sr_ovf_demo.agg (k INT, a DECIMAL(38,0) SUM)
AGGREGATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES('replication_num'='1');
INSERT INTO sr_ovf_demo.agg VALUES (1, 99999999999999999999999999999999999999);
INSERT INTO sr_ovf_demo.agg VALUES (1, 99999999999999999999999999999999999999);
SELECT a FROM sr_ovf_demo.agg;
-- -140282366920938463463374607431768211458Two inserts leave the table holding a negative number. No later query can recover the truth, because the inputs are gone.
StarRocks version (Required)
4.1.4-4a9848e (docker starrocks/allin1-ubuntu:latest) -- reproduces
3.5.21-85fccf8 -- reproducesAlso present in the main branch source at 1b1505c9 (2026-09-17): the accumulator, the
DECIMAL128 binding and the FE return-type rule quoted above are all from that checkout, and
the documented contract is from main's DECIMAL.md.
Source: StarRocks/starrocks