ZADD and ZRANGE

A Redis sorted set is a collection of unique string members where every member also carries a floating-point score. Redis keeps the members ordered by that score at all times, so you get set-like uniqueness plus the ability to ask questions like “who’s in first place?” or “give me everyone between rank 10 and 20.” ZADD is how you add members and set their scores, and ZRANGE is how you read them back in order. Together they’re the backbone of leaderboards, priority queues, rate-limit windows, and any feature that needs “top N” or “between X and Y” semantics.

Overview: How Sorted Sets Work

Internally, Redis backs a sorted set with two structures working together: a hash table that maps each member to its score (so ZSCORE is O(1)), and a skip list that keeps all members ordered by score (so range operations are fast without a full scan). A skip list is a probabilistic structure of linked, layered nodes that behaves like a balanced tree for practical purposes — it gives Redis O(log N) insertion, deletion, and rank lookups without the rebalancing overhead of a strict tree. When the sorted set is small, Redis actually uses a more memory-compact listpack encoding instead of the full skip list, and converts to the skip list encoding automatically once the set grows past a size threshold; you don’t have to think about this, but it explains why sorted sets stay cheap for small leaderboards and only pay the skip-list cost once they need to.

Every member in a sorted set is unique — you can’t have “alice” twice — but scores are not unique; many members can share the same score, in which case Redis breaks ties by comparing the members themselves lexicographically. Scores are stored as double-precision floats, so they can be integers, decimals, or even +inf/-inf for “always first” or “always last” sentinels. Because the whole operation happens on Redis’s single command-processing thread, a ZADD that adds ten members at once is atomic — no other client’s command can interleave and see a half-updated sorted set.

Syntax

ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]
ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]
Argument Meaning
key The sorted set’s key name.
NX Only add brand-new members; never update the score of an existing one.
XX Only update members that already exist; never add new ones.
GT / LT Only update a member’s score if the new score is greater than / less than its current score. (Cannot be combined with NX.)
CH Changes the return value from “number of new members added” to “number of members added or whose score changed.”
INCR Treats the operation like ZINCRBY for a single score/member pair, returning the new score instead of a count.
score member One or more score/member pairs to add or update.
start stop For plain ZRANGE, these are zero-based ranks (0 = lowest score, -1 = highest score, i.e. the last element).
BYSCORE Interpret start/stop as score bounds instead of ranks.
REV Return results from highest score to lowest instead of the default ascending order.
LIMIT offset count Only valid with BYSCORE/BYLEX; paginates the matched range.
WITHSCORES Include each member’s score alongside it in the reply.

Time complexity

Command Complexity
ZADD O(log N) per added element
ZRANGE O(log N + M), M = elements returned
ZSCORE O(1)
ZRANK O(log N)
ZINCRBY O(log N)

Examples

Example 1: Building a leaderboard

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

Output:

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

The first ZADD creates the key and adds three members in one atomic call, returning 3 for three new members. ZRANGE key 0 -1 means “give me every member, from rank 0 to the last rank,” and Redis returns them in ascending score order — alice (100) first, bob (250) last — not in the order they were inserted. Adding WITHSCORES interleaves each member with its score as a string.

Example 2: Updating scores and reading rank

ZADD leaderboard:global 100 "alice" 250 "bob" 175 "carol"
ZADD leaderboard:global 300 "bob"
ZSCORE leaderboard:global bob
ZINCRBY leaderboard:global 50 "alice"
ZREVRANGE leaderboard:global 0 -1 WITHSCORES
ZRANK leaderboard:global carol

Output:

(integer) 3
(integer) 0
"300"
"150"
1) "bob"
2) "300"
3) "carol"
4) "175"
5) "alice"
6) "150"
(integer) 1

Calling ZADD again on bob, who already exists, doesn’t add a new member — it overwrites his score to 300 and returns 0 (zero new members). ZSCORE confirms the new score. ZINCRBY is different: it adds to the existing score rather than replacing it, so alice’s score moves from 100 to 150. ZREVRANGE reads the same set from highest score to lowest — the natural order for “who’s winning.” ZRANK returns carol’s zero-based position counting from the lowest score: alice (150) is rank 0, carol (175) is rank 1, bob (300) is rank 2.

Example 3: Controlled writes with NX, GT, and CH

ZADD players:scores NX 10 "dave"
ZADD players:scores NX 99 "dave"
ZADD players:scores GT CH 5 "dave"
ZADD players:scores GT CH 20 "dave"
ZSCORE players:scores dave
ZRANGE players:scores 0 20 BYSCORE

Output:

(integer) 1
(integer) 0
(integer) 0
(integer) 1
"20"
1) "dave"

The first call creates dave with score 10. The second call uses NX, which refuses to touch an existing member, so dave’s score stays 10 and the reply is 0. The third call uses GT CH to say “only apply this if 5 is greater than dave’s current score” — 5 is not greater than 10, so nothing changes and CH reports 0 changed elements. The fourth call succeeds because 20 > 10, updating the score and reporting 1 changed element via CH. Finally, ZRANGE ... BYSCORE treats 0 and 20 as score bounds (not ranks) and returns every member whose score falls in that inclusive range.

How It Works Step by Step

When you run ZADD key score member, Redis: (1) looks up the key and confirms it either doesn’t exist yet or is already a sorted-set-typed value — otherwise it returns a WRONGTYPE error; (2) checks the member against the internal hash table to see if it already has a score; (3) if it’s new, inserts the member into both the hash table (member → score) and the skip list (ordered by score) in a single atomic step; (4) if it exists, removes it from its old position in the skip list and reinserts it at the position matching the new score, unless a flag like NX, XX, GT, or LT says not to. For ZRANGE, Redis walks the skip list starting at the layer that gets it closest to the requested start rank or score, then follows the bottom-level linked list forward, collecting members until it reaches the stop bound — this is why range reads are proportional to the skip list’s structure (O(log N)) plus the number of elements actually returned (O(M)), not the total set size.

Common Mistakes

Mistake 1: Assuming ZADD increments an existing score. It doesn’t — it overwrites.

ZADD scores:demo 10 "eve"
ZADD scores:demo 5 "eve"
ZSCORE scores:demo eve

Output:

(integer) 1
(integer) 0
"5"

eve’s score ends up as 5, not 15, because the second ZADD replaced it. If you want to add to a score, use ZINCRBY instead:

ZADD scores:demo2 10 "eve"
ZINCRBY scores:demo2 5 "eve"

Output:

(integer) 1
"15"

Mistake 2: Expecting ZRANGE to return highest-score-first. Plain ZRANGE is always ascending by score.

ZADD scores:demo3 1 "low" 2 "mid" 3 "high"
ZRANGE scores:demo3 0 -1

Output:

(integer) 3
1) "low"
2) "mid"
3) "high"

Newcomers often expect the “top” member first. For descending order, use ZREVRANGE, or ZRANGE key 0 -1 REV.

Mistake 3: Running a sorted-set command against the wrong type. Every Redis key has exactly one data type, and mismatches raise an error rather than silently coercing.

SET leaderboard:global "not a sorted set"
ZADD leaderboard:global 10 "alice"

Output:

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

Always check what type a key actually holds (with TYPE key) before assuming it’s safe to run sorted-set commands on it, especially with key names shared across features.

Best Practices

  • Use namespaced, descriptive key names like leaderboard:global or leaderboard:2026-08 rather than a single unbounded sorted set that mixes unrelated data.
  • Reach for ZINCRBY (or ZADD ... INCR) whenever the operation is “add to the current score,” like a vote or point tally — plain ZADD silently overwrites and is easy to misuse for this.
  • Use GT/LT with CH when you only want to record a best (or worst) score seen so far, such as a high-score table, without extra application-side logic.
  • Prefer ZRANGEBYSCORE/ZRANGE ... BYSCORE with LIMIT to paginate large ranges instead of pulling an entire sorted set into memory at once.
  • Remember that ZRANK/ZREVRANK give you a member’s position cheaply, which is often exactly what a “your current rank is #N” UI needs — no need to fetch and count the whole set client-side.
  • Set a TTL with EXPIRE on time-boxed leaderboards (daily/weekly contests) so they don’t accumulate forever; a plain SET on an unrelated string key never affects a sorted set’s TTL, but forgetting to set one at all is the real risk.

Practice Exercises

  • Create a sorted set race:results with five runner names and their finish times in seconds (lower is better). Use ZRANGE ... WITHSCORES to print the standings from fastest to slowest — think about whether you need REV given that a lower score means a better rank here.
  • Simulate a “like” counter for blog posts using a sorted set posts:likes, where each member is a post ID and its score is the like count. Use ZADD ... INCR (not increment-then-overwrite) to add one like to a post, then read the top 3 most-liked posts.
  • Add a member to a sorted set with ZADD ... NX, then try to lower its score with a second call that also uses NX. Predict the return value and the member’s final score before you check with ZSCORE, then explain why GT would have behaved differently.

Summary

  • ZADD adds or updates members with a floating-point score; by default it always overwrites an existing member’s score.
  • NX, XX, GT, and LT give you fine-grained control over whether an add or update is even allowed to happen.
  • CH changes what the return value counts (changed elements instead of only new ones), and INCR makes ZADD behave like ZINCRBY for one pair.
  • ZRANGE reads members in ascending score order by rank by default; use REV or ZREVRANGE for descending, and BYSCORE to query by score bounds instead of ranks.
  • Internally, a hash table gives O(1) score lookups and a skip list keeps members ordered for fast O(log N) range operations — this is why sorted sets scale well for leaderboards even as they grow large.
  • A type mismatch (running sorted-set commands on a key holding a string, list, etc.) always raises a WRONGTYPE error rather than silently doing the wrong thing.