Sorted Sets Explained

A Redis sorted set (or zset) is a collection of unique string members, where every member is associated with a floating-point score. Redis automatically keeps the members ordered by that score, so you can efficiently ask questions like "who is in first place," "what are the top 10," or "which members scored between 100 and 200" without sorting anything yourself. This makes sorted sets the natural tool for leaderboards, priority queues, rate limiters, and any data that needs both uniqueness and ranking.

Overview / How it works

A sorted set combines two guarantees that a plain SET cannot give you: every member is unique (like a set), and every member has an order (like a sorted array), determined by its score. Internally, Redis maintains a sorted set using two structures behind the scenes for large sets: a hash table mapping each member to its score (so ZSCORE is O(1)), and a skip list that keeps members ordered by score (so range operations like ZRANGE and ZRANGEBYSCORE are fast, O(log N) to find a starting point). A skip list is a probabilistic structure of layered linked lists that gives logarithmic search, insert, and delete without the rebalancing overhead of a tree.

For small sorted sets, Redis skips this dual structure entirely and stores the set as a compact listpack (a flat, memory-efficient sequence of score/member pairs) that is scanned linearly. Redis switches from listpack to the hash table + skip list representation once the set exceeds the thresholds set by zset-max-listpack-entries (default 128) or zset-max-listpack-value (default 64 bytes per member). This is why small sorted sets are extremely memory-efficient, and why the performance characteristics you read about (O(log N)) really only start to matter once a set grows large.

Because Redis is single-threaded, every sorted set command runs to completion before the next command starts — a ZINCRBY that reads the current score and writes a new one can never be interrupted by another client's write to the same key. That atomicity is what makes sorted sets safe for concurrent leaderboard updates without any external locking.

Two members can share the same score. When that happens, Redis breaks the tie by ordering those members lexicographically (plain byte-string comparison), which is also how ZRANGEBYLEX can be used to query a sorted set purely by member name when every score is identical.

Syntax

The core command for writing to a sorted set is:

ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
Argument Meaning
key The sorted set's key name.
NX Only add new members; never update the score of a member that already exists.
XX Only update scores of members that already exist; never add new members.
GT / LT Only update a member's score if the new score is greater / less than the current one. Not compatible with NX.
CH Return the number of members that were changed (added or had their score updated) instead of just the number added.
INCR Behave like ZINCRBY for a single score/member pair, returning the new score.
score A double-precision floating-point number (can also be +inf / -inf).
member The string identifying this entry; must be unique within the set.

Common companion commands include ZSCORE key member (get one member's score), ZRANGE key start stop [WITHSCORES] (get members by rank/index, ascending), ZREVRANGE key start stop [WITHSCORES] (descending), ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] (get members whose score falls in a range), ZRANK key member / ZREVRANK key member (a member's position), ZINCRBY key increment member (atomically adjust a score), ZREM key member [member ...] (delete members), and ZCARD key (count of members).

Examples

Example 1: A basic leaderboard

ZADD leaderboard:global 100 "alice"
ZADD leaderboard:global 250 "bob"
ZADD leaderboard:global 175 "carol"
ZRANGE leaderboard:global 0 -1 WITHSCORES
ZSCORE leaderboard:global "bob"
ZREVRANK leaderboard:global "bob"

Output:

(integer) 1
(integer) 1
(integer) 1
1) "alice"
2) "100"
3) "carol"
4) "175"
5) "bob"
6) "250"
"250"
(integer) 0

Each ZADD returns 1 because each member is new. ZRANGE 0 -1 returns the entire set in ascending score order, so alice (100) comes first and bob (250) last. ZSCORE confirms bob's score, and ZREVRANK returns 0 because bob has the highest score and ranks first when counting from the top.

Example 2: Atomic increments and range queries

ZADD game:scores 50 "player1"
ZADD game:scores 80 "player2"
ZINCRBY game:scores 30 "player1"
ZRANGEBYSCORE game:scores 60 100
ZCOUNT game:scores 60 100

Output:

(integer) 1
(integer) 1
"80"
1) "player1"
2) "player2"
(integer) 2

ZINCRBY adds 30 to player1's score (50 → 80) atomically and returns the new score as a string. Both players now have a score of 80, which falls in the 60–100 range, so ZRANGEBYSCORE returns both; since their scores tie, Redis orders them lexicographically (player1 before player2). ZCOUNT is the fast way to get just the count in a score range without transferring the members.

Example 3: Ranking, trimming, and removing

ZADD leaderboard:weekly 1200 "user:101"
ZADD leaderboard:weekly 950 "user:102"
ZADD leaderboard:weekly 1500 "user:103"
ZADD leaderboard:weekly 1100 "user:104"
ZREVRANGE leaderboard:weekly 0 2 WITHSCORES
ZRANK leaderboard:weekly "user:104"
ZREM leaderboard:weekly "user:102"
ZCARD leaderboard:weekly

Output:

(integer) 1
(integer) 1
(integer) 1
(integer) 1
1) "user:103"
2) "1500"
3) "user:101"
4) "1200"
5) "user:104"
6) "1100"
(integer) 1
(integer) 1
(integer) 3

ZREVRANGE 0 2 WITHSCORES pulls the top 3 by score, descending — exactly what a "top players this week" widget needs. ZRANK counts from the bottom (lowest score = rank 0), so user:104 (1100) is rank 1, ahead of only user:102. After removing user:102 with ZREM, ZCARD confirms 3 members remain.

How it works step by step

When you run ZADD leaderboard:global 250 "bob" on a key that doesn't exist yet, Redis: (1) creates a new sorted set, choosing the compact listpack encoding since the set is small; (2) inserts the score/member pair, keeping the listpack ordered by score; (3) returns the count of newly-added members. On a subsequent ZADD for a member that already exists (without NX), Redis instead locates the member, removes it from its old position, re-inserts it at the position matching its new score, and returns 0 since no new member was added — only the return value distinguishes an add from an update. As the set grows past the listpack thresholds, Redis transparently converts it to a hash table (member → score) plus a skip list (score-ordered structure), so lookups by member stay O(1) and range scans by score stay O(log N + M), where M is the number of elements returned. All of this happens as a single atomic step on Redis's one execution thread, so a concurrent reader never observes a half-updated sorted set.

Common Mistakes

Mistake 1: Calling a sorted set command on a key holding the wrong type

Every Redis key has exactly one data type. If a key already holds a plain string, calling ZADD on it fails:

SET session:abc123 "active"
ZADD session:abc123 100 "user:1"

Output:

OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value

Redis is telling you session:abc123 is a string, not a sorted set. Fix it by choosing a distinct, namespaced key for the sorted set (e.g. leaderboard:global instead of reusing a key meant for something else), or by deleting/renaming the conflicting key first if that's genuinely what you intend.

Mistake 2: Swapping the argument order

ZADD always takes the score before the member. Writing them in the wrong order produces an error because Redis tries to parse the member string as a floating-point score:

ZADD leaderboard:global "dave" 300

Output:

(error) ERR value is not a valid float

The fix is simply to put the score first: ZADD leaderboard:global 300 "dave".

Mistake 3: Read-then-write instead of an atomic increment

A subtle but common bug: computing a player's new score in your application (read the old score, add to it, then ZADD the result) instead of using ZINCRBY. This is a classic race condition — two concurrent requests can both read the same starting score and one update silently overwrites the other.

ZADD leaderboard:global 100 "alice"
ZSCORE leaderboard:global "alice"
ZADD leaderboard:global 250 "alice"
ZSCORE leaderboard:global "alice"

Output:

(integer) 1
"100"
(integer) 0
"250"

Notice the second ZADD returns 0 (no new member added) and silently replaces the score — if that 250 was computed client-side from a stale read of 100, any concurrent increment is lost. Always prefer ZINCRBY key increment member when the intent is "add points," since the increment happens atomically on the server.

Best Practices

  • Use ZINCRBY (or ZADD ... INCR) for relative score changes instead of a read-then-write from your application, to avoid race conditions.
  • Namespace keys clearly, e.g. leaderboard:weekly, leaderboard:global, so sorted sets never collide with unrelated keys of a different type.
  • Use ZRANGEBYSCORE with LIMIT offset count for pagination through large ranges instead of pulling the whole set with ZRANGE 0 -1.
  • Use ZADD ... NX when you only ever want to insert a member once and never let a later call overwrite its score.
  • Remember member scores are doubles — for very large integer-like scores (timestamps, IDs), be aware of floating-point precision limits around 2^53.
  • Set a TTL with EXPIRE on time-bounded leaderboards (e.g. a "this hour" leaderboard) so old keys don't accumulate forever.
  • When two use cases need both order and lookup by name, a sorted set is usually the right structure — don't reach for a plain SET plus manual sorting in your application.

Practice Exercises

  • Exercise 1: Create a sorted set game:leaderboard with five players and distinct scores of your choosing. Retrieve the top 3 players, highest score first, along with their scores.
  • Exercise 2: Using the same key, atomically award 15 bonus points to one player after a match ends, then confirm their new score with ZSCORE. Do not compute the new score yourself and overwrite it with ZADD.
  • Exercise 3: Find every player in a "silver tier" scoring between 100 and 199 inclusive using ZRANGEBYSCORE, then get just the count of players in that tier using ZCOUNT.

Summary

  • A sorted set stores unique string members, each with a floating-point score, and keeps them ordered by that score automatically.
  • Small sorted sets use a compact listpack encoding; large ones use a hash table (for O(1) score lookups) plus a skip list (for O(log N) range operations).
  • Members with equal scores are ordered lexicographically as a tiebreaker.
  • ZADD adds or updates; it returns the count of newly added members, not updated ones, unless you pass CH.
  • Use ZINCRBY for atomic score changes to avoid race conditions from a read-then-write pattern.
  • ZRANGE/ZREVRANGE query by rank/position; ZRANGEBYSCORE/ZCOUNT query by score range.
  • Sorted set commands fail with WRONGTYPE if the key already holds a different data type.
  • Sorted sets are ideal for leaderboards, priority queues, and range-based ranking — not a general substitute for relational queries.