Skip to content

Sorted Sets

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcripts are one session in document order, replayed by the course harness.

A Sorted Set is like a Set, but each member carries a score (a 64-bit float). Redis keeps members ordered by score at all times, with O(log N) inserts and range queries. Members are unique; scores are not. Members with equal scores are ordered by their bytes (shown below).

ZADD key [NX|XX] [GT|LT] [CH] score member [score member ...] adds members. An existing member has its score updated. The reply counts new members only, unless you pass CH (changed) to count updates too.

cli/sets-sorted-sets-sorted-sets-1.redis
127.0.0.1:6379> ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"
(integer) 3
127.0.0.1:6379> ZADD game:scores 4700 "alice"
(integer) 0
127.0.0.1:6379> ZSCORE game:scores "alice"
"4700"
127.0.0.1:6379> ZADD game:scores NX 1 "alice" 100 "dave"
(integer) 1
127.0.0.1:6379> ZADD game:scores XX 9999 "erin"
(integer) 0
127.0.0.1:6379> ZADD game:scores GT 4000 "alice"
(integer) 0
127.0.0.1:6379> ZADD game:scores GT CH 6000 "alice"
(integer) 1
127.0.0.1:6379> ZADD game:scores LT CH 5000 "alice"
(integer) 1
127.0.0.1:6379> ZRANGE game:scores 0 -1 WITHSCORES
1) "dave"
2) "100"
3) "bob"
4) "3800"
5) "alice"
6) "5000"
7) "carol"
8) "5100"

Reading it line by line:

  • ZADD ... 4700 "alice" updated an existing member, so the reply is 0 new members, and ZSCORE shows "4700", a string.
  • NX = only add new members (alice untouched, dave added: 1). XX = only update existing ones (erin does not exist, nothing happens: 0).
  • GT = only update if the new score is greater; LT = only if less. Both still add missing members. GT 4000 on 4700 changed nothing (0); GT CH 6000 raised it and CH made the reply 1. LT CH 5000 then lowered it. GT/LT/NX cannot be combined with each other.
ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"
ZADD game:scores 4700 "alice"
ZSCORE game:scores "alice"
ZADD game:scores GT CH 6000 "alice"

ZRANGE — by rank, by score, reversed, paged

Section titled “ZRANGE — by rank, by score, reversed, paged”

Since 6.2 a single command does all ranges: ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES]. REV reverses the order (highest first); with BYSCORE, the bounds are scores, and you pass them high first when combined with REV. A leading ( makes a bound exclusive.

cli/sets-sorted-sets-sorted-sets-2.redis
127.0.0.1:6379> ZRANGE game:scores 0 2 REV WITHSCORES
1) "carol"
2) "5100"
3) "alice"
4) "5000"
5) "bob"
6) "3800"
127.0.0.1:6379> ZRANGE game:scores 4000 +inf BYSCORE LIMIT 0 2 WITHSCORES
1) "alice"
2) "5000"
3) "carol"
4) "5100"
127.0.0.1:6379> ZRANGE game:scores "(4000" +inf BYSCORE
1) "alice"
2) "carol"
127.0.0.1:6379> ZRANGE game:scores +inf 4000 BYSCORE REV WITHSCORES
1) "carol"
2) "5100"
3) "alice"
4) "5000"
127.0.0.1:6379> ZRANGESTORE top2 game:scores 0 1 REV
(integer) 2
127.0.0.1:6379> ZRANGE top2 0 -1 WITHSCORES
1) "alice"
2) "5000"
3) "carol"
4) "5100"

ZRANGESTORE dst src start stop [BYSCORE|BYLEX] [REV] [LIMIT ...] (6.2+) writes a slice of a sorted set into another key, which is how a “top 2” snapshot was stored above.

ZADD leaderboard 900 "carol" 1500 "alice" 2100 "bob" 750 "dave"
ZRANGE leaderboard 0 -1 WITHSCORES
ZRANGE leaderboard 0 2 REV WITHSCORES
ZADD temps 18.5 "london" 32.1 "bangkok" 24.0 "berlin" 28.7 "tokyo"
ZRANGE temps 20 30 BYSCORE WITHSCORES

ZRANK key member [WITHSCORE] returns the 0-based rank ascending; ZREVRANK the rank descending (highest score = rank 0). WITHSCORE (7.2+) returns [rank, score] in one call. A missing member gives (nil).

cli/sets-sorted-sets-sorted-sets-3.redis
127.0.0.1:6379> ZRANK game:scores "alice"
(integer) 2
127.0.0.1:6379> ZRANK game:scores "alice" WITHSCORE
1) (integer) 2
2) "5000"
127.0.0.1:6379> ZREVRANK game:scores "alice"
(integer) 1
127.0.0.1:6379> ZREVRANK game:scores "alice" WITHSCORE
1) (integer) 1
2) "5000"
127.0.0.1:6379> ZRANK game:scores "nobody"
(nil)
ZADD players 300 "x" 600 "y" 900 "z"
ZRANK players "y"
ZREVRANK players "y" WITHSCORE
ZSCORE players "z"

Ties and precision — two things scores do not do for you

Section titled “Ties and precision — two things scores do not do for you”

Ties are broken by the member’s bytes, not by insertion time. z (score 0) is first; a, b, c share score 1 and come out alphabetically. It is a byte comparison: "10" sorts before "9", and uppercase B sorts before lowercase a. Scores are IEEE-754 doubles: integers are exact only up to 2^53 (9,007,199,254,740,992); ZADD big 9007199254740993 x stores ...992. Decimal fractions carry the usual float error (0.1 + 0.2 = 0.30000000000000004).

cli/sets-sorted-sets-sorted-sets-4.redis
127.0.0.1:6379> ZADD ties 1 b 1 a 1 c 0 z
(integer) 4
127.0.0.1:6379> ZRANGE ties 0 -1 WITHSCORES
1) "z"
2) "0"
3) "a"
4) "1"
5) "b"
6) "1"
7) "c"
8) "1"
127.0.0.1:6379> ZADD ties2 1 a 1 B 1 9 1 10
(integer) 4
127.0.0.1:6379> ZRANGE ties2 0 -1
1) "10"
2) "9"
3) "B"
4) "a"
127.0.0.1:6379> ZADD big 9007199254740993 x
(integer) 1
127.0.0.1:6379> ZSCORE big x
"9007199254740992"
127.0.0.1:6379> ZADD big 0.1 y
(integer) 1
127.0.0.1:6379> ZINCRBY big 0.2 y
"0.30000000000000004"
127.0.0.1:6379> ZADD bad notanumber m
(error) ERR value is not a valid float

A non-numeric score is rejected (ERR value is not a valid float). If you must rank by a big integer such as a microsecond timestamp, stay under 2^53 or split the value; the leaderboards lesson encodes a tie-break inside the score without crossing that line.

ZINCRBY key increment member atomically adds to a member’s score (creating it at increment if absent). ZCARD key counts members, ZREM key member [member ...] removes them and returns how many existed.

cli/sets-sorted-sets-sorted-sets-5.redis
127.0.0.1:6379> ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
(integer) 3
127.0.0.1:6379> ZINCRBY votes 3 "post:2"
"8"
127.0.0.1:6379> ZCARD votes
(integer) 3
127.0.0.1:6379> ZREM votes "post:3"
(integer) 1
127.0.0.1:6379> ZCARD votes
(integer) 2
ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
ZINCRBY votes 3 "post:2"
ZCARD votes
ZREM votes "post:3"
ZCARD votes

From Node.js: result shapes and a lost-update race

Section titled “From Node.js: result shapes and a lost-update race”

ioredis returns ZRANGE ... WITHSCORES as a flat array [member, score, member, score, ...] with scores as strings. The script also races 50 concurrent vote increments done two ways:

src/sets-sorted-sets-sorted-sets-scores.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const key = 'b2:z:scores';
await redis.del(key, 'b2:z:votes-bug', 'b2:z:votes-ok');
await redis.zadd(key, 4200, 'alice', 3800, 'bob', 5100, 'carol');
// ZRANGE ... REV WITHSCORES returns a FLAT array: [member, score, member, score, ...], scores are strings
const flat = await redis.zrange(key, 0, '1', 'REV', 'WITHSCORES'); // ioredis types want stop as a string
console.log(flat); // [ 'carol', '5100', 'alice', '4200' ]
const top = [];
for (let i = 0; i < flat.length; i += 2) top.push({ member: flat[i], score: Number(flat[i + 1]) });
console.log(top);
// range by score, paged: score >= 4000, first 2
console.log(await redis.zrange(key, 4000, '+inf', 'BYSCORE', 'LIMIT', 0, 2)); // [ 'alice', 'carol' ]
// ZADD flags: GT only raises a score, CH reports changed members (not just new ones)
console.log(await redis.zadd(key, 'GT', 'CH', 1000, 'alice')); // 0 (lower score ignored)
console.log(await redis.zadd(key, 'GT', 'CH', 6000, 'alice')); // 1
// rank with score (7.2+); null for a missing member
console.log(await redis.zrank(key, 'alice', 'WITHSCORE')); // [ 2, '6000' ]
console.log(await redis.zrank(key, 'nobody')); // null
// BUG vs FIX: 50 concurrent "vote +1" on one member
const bug = async () => { const cur = Number((await redis.zscore('b2:z:votes-bug', 'post')) ?? 0); await redis.zadd('b2:z:votes-bug', cur + 1, 'post'); };
await Promise.all(Array.from({ length: 50 }, bug));
await Promise.all(Array.from({ length: 50 }, () => redis.zincrby('b2:z:votes-ok', 1, 'post')));
console.log('read-modify-write ZADD:', await redis.zscore('b2:z:votes-bug', 'post')); // far below 50
console.log('ZINCRBY:', await redis.zscore('b2:z:votes-ok', 'post')); // 50
await redis.del(key, 'b2:z:votes-bug', 'b2:z:votes-ok');
await redis.quit();

In ioredis’ TypeScript types stop is string | Buffer, so pass '1' or '-1', not a number. Real output (this Mac):

[ 'carol', '5100', 'alice', '4200' ]
[ { member: 'carol', score: 5100 }, { member: 'alice', score: 4200 } ]
[ 'alice', 'carol' ]
0
1
[ 2, '6000' ]
null
read-modify-write ZADD: 1
ZINCRBY: 50

An assistant writes an upvote handler for a post ranking:

🐛 Spot the bug

async function upvote(postId: string) {
const cur = Number((await redis.zscore('posts:votes', postId)) ?? 0);
await redis.zadd('posts:votes', cur + 1, postId); // set the new score
}
Show the answer

ZADD sets the score to whatever you tell it; the value was computed from an earlier read, so two requests that read the same cur both write cur + 1 and one vote vanishes. With 50 concurrent upvotes the script above ended at 1, not 50. ZINCRBY does the add on the server, atomically, and ended at 50:

async function upvote(postId: string) {
await redis.zincrby('posts:votes', 1, postId);
}

A Sorted Set is an index you keep by hand: the score is the sort key, the member is the row id, and Redis maintains the order. Anything you can express as “one number, ascending” (points, timestamps, priorities, distances) becomes a range query. Pick the number carefully: it must fit a double, ties fall back to byte order, and changing it means updating every member.

ChoiceBenefitCost
Sorted Set instead of a SQL table with ORDER BYO(log N) inserts and range reads, no index to maintainOne float64 score: no multi-column sort, no exact integers above 2^53
ZRANGE ... BYSCORE for range queriesFetch a score window without scanningThe score must be designed up front; changing it means a re-score
ZINCRBY for countersAtomic, one round tripOnly adds; for compare-and-set logic use GT/LT flags or a script
Tie order = member bytesDeterministicNot insertion order, and "10" sorts before "9"
  • “ZADD on an existing member creates a duplicate.” It updates the score and replies 0 (or 1 with CH).
  • “ZRANGE only sorts ascending by rank.” Since 6.2 it takes REV, BYSCORE, BYLEX and LIMIT; the separate ZREV*/ZRANGEBY* commands are deprecated.
  • “Equal scores are ordered by insertion time.” They are ordered by the member’s bytes.
  • “Scores can hold any 64-bit integer.” They are doubles: exact only to 2^53.
  • “ZREVRANK is deprecated too.” It is not; it also supports WITHSCORE.
  • Read-modify-write with ZADD — ZSCORE then ZADD cur+1 loses updates (1 of 50 survived). Use ZINCRBY.
  • Teaching or copying ZREVRANGE/ZRANGEBYSCORE — they work, but use ZRANGE ... REV/BYSCORE.
  • Swapping the bounds — with BYSCORE REV the higher bound comes first.
  • Assuming WITHSCORES gives pairs — clients return a flat array; ioredis leaves scores as strings.

Real-world example

Redis’s own documentation presents leaderboards, rate limiters and priority queues as the main Sorted Set uses and documents the deprecations in the command pages (Sorted sets, ZRANGE).

How do you get the top 3 members, highest score first, with the current (non-deprecated) command?
What does ZRANK return for a member?
What happens when you ZADD a score for a member that already exists (no flags)?
Fifty clients each do ZSCORE, add 1 in Node, then ZADD. What is the final score?
Three members have score 1: b, a, c. How does ZRANGE order them?
You store the score 9007199254740993 (2^53 + 1). What does ZSCORE return?