#15812·Redis

[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

Author: hanke580Created Sep 17, 2026Updated Sep 17, 2026

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:

  1. Preserve the sign in both — pass the score through the d2string() signed-zero branch on the listpack path too, e.g. by rejecting -0.0 in double2ll() (if (ll == d && !(ll == 0 && signbit(d)))) or by testing for it in zzlInsertAt() before the double2ll() call. This keeps the skiplist behaviour, which is the faithful one: a Redis score is an IEEE double and -0.0 is a distinct bit pattern that Redis elsewhere deliberately formats as -0.
  2. Normalise it away in both — which is what Valkey does; it returns 0 at 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.