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.
What is a Redis Sorted Set?
Section titled “What is a Redis Sorted Set?”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 — adding members with scores
Section titled “ZADD — adding members with scores”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.
127.0.0.1:6379> ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"(integer) 3127.0.0.1:6379> ZADD game:scores 4700 "alice"(integer) 0127.0.0.1:6379> ZSCORE game:scores "alice""4700"127.0.0.1:6379> ZADD game:scores NX 1 "alice" 100 "dave"(integer) 1127.0.0.1:6379> ZADD game:scores XX 9999 "erin"(integer) 0127.0.0.1:6379> ZADD game:scores GT 4000 "alice"(integer) 0127.0.0.1:6379> ZADD game:scores GT CH 6000 "alice"(integer) 1127.0.0.1:6379> ZADD game:scores LT CH 5000 "alice"(integer) 1127.0.0.1:6379> ZRANGE game:scores 0 -1 WITHSCORES1) "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 is0new members, andZSCOREshows"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 4000on 4700 changed nothing (0);GT CH 6000raised it andCHmade the reply1.LT CH 5000then lowered it.GT/LT/NXcannot 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.
127.0.0.1:6379> ZRANGE game:scores 0 2 REV WITHSCORES1) "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 WITHSCORES1) "alice"2) "5000"3) "carol"4) "5100"127.0.0.1:6379> ZRANGE game:scores "(4000" +inf BYSCORE1) "alice"2) "carol"127.0.0.1:6379> ZRANGE game:scores +inf 4000 BYSCORE REV WITHSCORES1) "carol"2) "5100"3) "alice"4) "5000"127.0.0.1:6379> ZRANGESTORE top2 game:scores 0 1 REV(integer) 2127.0.0.1:6379> ZRANGE top2 0 -1 WITHSCORES1) "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 WITHSCORESZADD temps 18.5 "london" 32.1 "bangkok" 24.0 "berlin" 28.7 "tokyo"
ZRANGE temps 20 30 BYSCORE WITHSCORESZRANK and ZSCORE — finding a member
Section titled “ZRANK and ZSCORE — finding a member”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).
127.0.0.1:6379> ZRANK game:scores "alice"(integer) 2127.0.0.1:6379> ZRANK game:scores "alice" WITHSCORE1) (integer) 22) "5000"127.0.0.1:6379> ZREVRANK game:scores "alice"(integer) 1127.0.0.1:6379> ZREVRANK game:scores "alice" WITHSCORE1) (integer) 12) "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).
127.0.0.1:6379> ZADD ties 1 b 1 a 1 c 0 z(integer) 4127.0.0.1:6379> ZRANGE ties 0 -1 WITHSCORES1) "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) 4127.0.0.1:6379> ZRANGE ties2 0 -11) "10"2) "9"3) "B"4) "a"127.0.0.1:6379> ZADD big 9007199254740993 x(integer) 1127.0.0.1:6379> ZSCORE big x"9007199254740992"127.0.0.1:6379> ZADD big 0.1 y(integer) 1127.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 floatA 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, ZCARD and ZREM
Section titled “ZINCRBY, ZCARD and ZREM”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.
127.0.0.1:6379> ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"(integer) 3127.0.0.1:6379> ZINCRBY votes 3 "post:2""8"127.0.0.1:6379> ZCARD votes(integer) 3127.0.0.1:6379> ZREM votes "post:3"(integer) 1127.0.0.1:6379> ZCARD votes(integer) 2ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
ZINCRBY votes 3 "post:2"
ZCARD votes
ZREM votes "post:3"
ZCARD votesFrom 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:
// @runimport { 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 stringsconst flat = await redis.zrange(key, 0, '1', 'REV', 'WITHSCORES'); // ioredis types want stop as a stringconsole.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 2console.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 memberconsole.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 memberconst 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 50console.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' ]01[ 2, '6000' ]nullread-modify-write ZADD: 1ZINCRBY: 50Reading AI-generated code
Section titled “Reading AI-generated code”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);}Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Sorted Set instead of a SQL table with ORDER BY | O(log N) inserts and range reads, no index to maintain | One float64 score: no multi-column sort, no exact integers above 2^53 |
ZRANGE ... BYSCORE for range queries | Fetch a score window without scanning | The score must be designed up front; changing it means a re-score |
ZINCRBY for counters | Atomic, one round trip | Only adds; for compare-and-set logic use GT/LT flags or a script |
| Tie order = member bytes | Deterministic | Not insertion order, and "10" sorts before "9" |
Common Misconceptions
Section titled “Common Misconceptions”- “
ZADDon an existing member creates a duplicate.” It updates the score and replies0(or1withCH). - “
ZRANGEonly sorts ascending by rank.” Since 6.2 it takesREV,BYSCORE,BYLEXandLIMIT; the separateZREV*/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.
- “
ZREVRANKis deprecated too.” It is not; it also supportsWITHSCORE.
Common mistakes
Section titled “Common mistakes”- Read-modify-write with
ZADD—ZSCOREthenZADD cur+1loses updates (1 of 50 survived). UseZINCRBY. - Teaching or copying
ZREVRANGE/ZRANGEBYSCORE— they work, but useZRANGE ... REV/BYSCORE. - Swapping the bounds — with
BYSCORE REVthe higher bound comes first. - Assuming
WITHSCORESgives 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).