[BUG] `ZADD key -0 member` loses the sign of zero in listpack-encoded sorted sets, so the same command stores a different value depending on the key's size
Describe the bug
A sorted-set score of negative zero survives in the skiplist encoding and is
normalised to positive zero in the listpack encoding. Since the encoding is
chosen from the member count against zset-max-listpack-entries (128 by
default), the value ZADD stores depends on how many other members the key
happens to hold when the command runs:
sent members already present encoding ZSCORE
-0 0 listpack "0"
-0 127 listpack "0"
-0 128 skiplist "-0"
-0 300 skiplist "-0"
ZRANGE ... WITHSCORES shows the same split. No configuration is changed
anywhere above — this is the shipped default threshold being crossed.
Every spelling of negative zero is affected, and no spelling of positive zero is:
| sent | ≤ 127 members | ≥ 128 members | |
|---|---|---|---|
-0 |
0 |
-0 |
differs |
-0.0 |
0 |
-0 |
differs |
-0e0 |
0 |
-0 |
differs |
-0.000 |
0 |
-0 |
differs |
0, +0, 0.0 |
0 |
0 |
consistent |
The loss is permanent. Growing the key afterwards does not restore the sign, because the listpack discarded it on the way in:
stored at 10 members: "0" -> after growing to 211 members (skiplist): "0"
So the value a key ends up holding depends on the order in which it was built, not only on what was written. Two keys given the same members in different orders can hold different scores.
Root cause
zzlInsertAt() converts the score to an integer before consulting the
function that knows about signed zero. src/t_zset.c:1250:
static unsigned char *zzlInsertAt(unsigned char *zl, unsigned char *eptr, sds ele, double score) {
char scorebuf[MAX_D2STRING_CHARS];
int scorelen = 0;
long long lscore;
int score_is_long = double2ll(score, &lscore); /* <-- -0.0 becomes 0 here */
if (!score_is_long)
scorelen = d2string(scorebuf,sizeof(scorebuf),score);
double2ll() (src/util.c:685) casts through long long and accepts the
result whenever ll == d. For -0.0 that test is true, because 0 == -0.0 in
IEEE comparison, so it returns 1 with *out = 0 and the sign is gone:
double2ll(-0.0) returned 1, *out = 0
signbit survives in the double? yes
but after long long round trip? no
The score is then stored as a listpack integer, and zzlGetScore()
(src/t_zset.c) reads it back with score = vlong; — a positive zero.
Redis already handles this correctly one call away. d2string()
(src/util.c:731) has an explicit signed-zero branch, with a citation:
} else if (value == 0) {
/* See: http://en.wikipedia.org/wiki/Signed_zero, "Comparisons". */
if (1.0/value < 0)
len = snprintf(buf,len,"-0");
else
len = snprintf(buf,len,"0");
}
That branch is simply never reached for -0, because double2ll() claims the
value first. The skiplist path keeps the double and formats it through the same
d2string() logic on reply, which is why it prints -0.
The equivalent DATE/integer split does not exist for any other collection — see the sweep below.
To reproduce
The shortest version isolates the encoding directly and needs no 128 members:
redis-cli> DEL k
redis-cli> ZADD k -0 m
redis-cli> ZSCORE k m
"0" <-- listpack
redis-cli> CONFIG SET zset-max-listpack-entries 0
redis-cli> DEL k
redis-cli> ZADD k -0 m
redis-cli> ZSCORE k m
"-0" <-- skiplist, same command, same input
At shipped defaults, with nothing configured, the member count alone does it:
redis-cli> DEL k
redis-cli> ZADD k -0 m
redis-cli> OBJECT ENCODING k => "listpack"
redis-cli> ZSCORE k m => "0"
# add 130 unrelated members, then read the same member back
redis-cli> ZADD k 1 f1 ... 130 f130
redis-cli> OBJECT ENCODING k => "skiplist"
redis-cli> ZSCORE k m => "0" <-- sign never comes back
# build the same key the other way round: 130 members first, then the -0
redis-cli> DEL j
redis-cli> ZADD j 1 f1 ... 130 f130
redis-cli> ZADD j -0 m
redis-cli> OBJECT ENCODING j => "skiplist"
redis-cli> ZSCORE j m => "-0" <-- same members, same scores
Two keys, identical contents, different insertion order, different stored value.
Scripted versions are attached to this report's working folder:
repro.py prints the threshold table and all three read paths;
encoding_sweep.py runs the sweep described below.
Expected behavior
ZADD key -0 member should store the same score regardless of the key's
current encoding. Encoding conversion is an internal storage decision and must
not be observable.
Either normalisation is defensible, as long as it is consistent:
- Preserve the sign in both — pass the score through the
d2string()signed-zero branch on the listpack path too, e.g. by rejecting-0.0indouble2ll()(if (ll == d && !(ll == 0 && signbit(d)))) or by testing for it inzzlInsertAt()before thedouble2ll()call. This keeps the skiplist behaviour, which is the faithful one: a Redis score is an IEEE double and-0.0is a distinct bit pattern that Redis elsewhere deliberately formats as-0. - Normalise it away in both — which is what Valkey does; it returns
0at every size, including in its own B-tree encoding.
Option 1 looks like the smaller change and matches the existing intent of
d2string().
Additional information
Reproduced on current unstable
| build | ≤ 127 members | ≥ 128 members |
|---|---|---|
unstable @ 2b32b178, v=8.9.241, built from source 2026-09-17 |
0 |
-0 |
| Redis 8.2.9 (released) | 0 |
-0 |
Valkey unstable @ 00a8a19 |
0 |
0 |
2b32b178 was unstable HEAD at the time of writing (committed 2026-09-17
05:32 +0300). Not a regression — the same split is present in the released 8.2.x
line.
It is the only value the two encodings disagree about
To establish that this is the only such disagreement rather than the first one
a random search happened to hit, encoding_sweep.py writes a battery of awkward
values into a small instance and a large one and compares what comes back, for
every collection type Redis has:
| probe | values tried | differ |
|---|---|---|
| zset score | 31 | 4 |
zset ZINCRBY |
31 | 0 |
| zset member | 21 | 0 |
| set member | 21 | 0 |
| hash value | 21 | 0 |
| hash field | 21 | 0 |
| list element | 21 | 0 |
The four are -0, -0.0, -0e0 and -0.000, which are one defect. The values
tried include integer-like strings an intset or listpack might re-render (007,
+1, 1e3, 0x10, 9223372036854775808), float spellings with more than one
text form (1e3, 3.0e2, 0.1, 1e-308), and the boundaries of the integer
range. None of them moves. The same sweep against Valkey unstable reports
0 of 167.
Source: redis/redis