ZRANGEBYSCORE and Range Queries
A Redis sorted set stores a group of unique members, each tagged with a floating-point score, and keeps every member ordered by that score automatically. ZRANGEBYSCORE is the command you reach for whenever you need to pull members out of a sorted set based on a range of scores — "everyone with more than 1000 points," "every event between two timestamps," or "every product priced under $50" — rather than a range of positions. It turns a sorted set into a fast, in-memory range index, something a plain SET or hash simply can’t do, which is why it sits at the center of leaderboards, time-series buckets, and priced or scheduled inventories built on Redis.
Overview / How It Works
A sorted set (created with ZADD and read with commands like ZSCORE, ZRANGE, and ZRANGEBYSCORE) is really two synchronized structures living under one key: a hash table mapping each member to its score for O(1) lookups, and a skip list that keeps every member-score pair ordered by score for fast range traversal. A skip list is a linked structure with several "levels" of forward pointers — the more levels a node has, the further a search can jump in a single step — giving an average O(log N) time to locate the start of a range, similar in spirit to a balanced tree but far simpler to implement safely. When a sorted set is small (by default, up to 128 entries with each member no longer than 64 bytes), Redis instead stores it as a compact listpack and just scans it linearly; the switch to skip list plus hash table happens automatically as the set grows, and it’s invisible to you as a client.
ZRANGEBYSCORE key min max tells Redis to walk the skip list starting at the first node whose score is greater than or equal to min, then keep returning members left to right (ascending score order) until the score passes max. Because the skip list is already sorted, Redis never sorts anything at query time — it seeks directly to the start of the range in roughly logarithmic time, then walks linearly only across the elements it actually returns. That’s what the O(log(N)+M) time complexity you’ll see throughout this lesson means in practice: N is the total size of the sorted set, M is the number of elements your range actually matches. A narrow range on a huge sorted set is cheap. An unbounded range (-inf to +inf) on a huge sorted set returns everything and costs O(N) — the same risk profile as KEYS *, just scoped to one key instead of the whole keyspace.
Because every Redis command runs to completion on the single main thread before the next one begins, a ZRANGEBYSCORE call is atomic with respect to concurrent writers — no other client’s ZADD can interleave mid-scan and hand you a half-updated view. That same single-threaded design is exactly why an unbounded, high-cardinality range query is dangerous in production: while it runs, it blocks every other command waiting on that Redis instance.
Time Complexity Reference
| Command | Time Complexity | Notes |
|---|---|---|
ZADD |
O(log(N)) per added member | N = sorted set size after the add |
ZRANGEBYSCORE |
O(log(N)+M) | M = number of elements returned |
ZREVRANGEBYSCORE |
O(log(N)+M) | Same cost, descending order |
ZCOUNT |
O(log(N)) | Counts without transferring members |
ZRANGE key min max BYSCORE |
O(log(N)+M) | Redis 6.2+ unified syntax, same cost as ZRANGEBYSCORE |
Syntax
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
key— the sorted set to query. If the key doesn’t exist, Redis treats it as an empty sorted set and returns an empty array, not an error.min/max— the score bounds, inclusive by default. Use the literals-infand+infto mean "no lower bound" and "no upper bound."- Exclusive bounds — prefix a bound with
(to make it exclusive, e.g.(100means "greater than 100, not equal to it." WITHSCORES— optional; includes each member’s score as a separate element right after the member in the reply.LIMIT offset count— optional; skipsoffsetmatching elements before collectingcountof them, for pagination. It only makes sense alongside a min/max range, and can only be used once per call.
Since Redis 6.2, the unified ZRANGE key min max BYSCORE form can do the same thing (plus REV for descending order), and is the form the Redis docs now recommend for new code. ZRANGEBYSCORE itself is not deprecated — it’s stable, widely used in existing code, and perfectly fine to reach for; this lesson uses it throughout because it’s still the most common form you’ll encounter. Its mirror command, ZREVRANGEBYSCORE key max min, does the same walk in descending order — note the argument order flips to max first, then min.
Examples
Example 1: Filtering a leaderboard by score range
A game leaderboard is a natural sorted set: the member is the player, the score is their points.
ZADD leaderboard:global 100 "alice"
ZADD leaderboard:global 250 "bob"
ZADD leaderboard:global 175 "carol"
ZADD leaderboard:global 300 "dave"
ZRANGEBYSCORE leaderboard:global 150 300
Output:
(integer) 1
(integer) 1
(integer) 1
(integer) 1
1) "carol"
2) "bob"
3) "dave"
Each ZADD returns (integer) 1 because each call adds one brand-new member. The final ZRANGEBYSCORE returns everyone scoring between 150 and 300 inclusive, in ascending score order — alice (100) is correctly excluded since she’s below the 150 floor.
Example 2: WITHSCORES and pagination with LIMIT
A product catalog can use price as the score, letting you query "everything under $X" directly.
ZADD products:price 19.99 "widget:small"
ZADD products:price 49.99 "widget:medium"
ZADD products:price 99.99 "widget:large"
ZADD products:price 149.99 "widget:xlarge"
ZRANGEBYSCORE products:price 0 100 WITHSCORES
ZRANGEBYSCORE products:price 0 200 LIMIT 1 2
Output:
(integer) 1
(integer) 1
(integer) 1
(integer) 1
1) "widget:small"
2) "19.989999999999998"
3) "widget:medium"
4) "49.990000000000002"
5) "widget:large"
6) "99.989999999999995"
1) "widget:medium"
2) "widget:large"
The first query returns every product priced from 0 to 100, with each score included right after its member because of WITHSCORES. The second query widens the range to 0–200 (matching all four products) but adds LIMIT 1 2, which skips the first matching product (widget:small) and returns the next two — a simple way to page through a wide range without pulling every match to the client.
Example 3: Exclusive bounds, ZREVRANGEBYSCORE, and ZCOUNT
Timestamps make great scores for event logs, and exclusive bounds let you avoid re-processing an event you’ve already handled.
ZADD events:2026-08-10 1723200000 "login:user42"
ZADD events:2026-08-10 1723200300 "purchase:user42"
ZADD events:2026-08-10 1723200600 "logout:user42"
ZRANGEBYSCORE events:2026-08-10 (1723200000 +inf
ZREVRANGEBYSCORE events:2026-08-10 +inf -inf
ZCOUNT events:2026-08-10 -inf +inf
Output:
(integer) 1
(integer) 1
(integer) 1
1) "purchase:user42"
2) "logout:user42"
1) "logout:user42"
2) "purchase:user42"
3) "login:user42"
(integer) 3
The (1723200000 prefix makes the lower bound exclusive, so the login event at exactly that timestamp is skipped — useful if you’ve already processed "everything up to and including 1723200000" and want strictly newer events next time. ZREVRANGEBYSCORE walks the same data in descending order (note max comes before min), and ZCOUNT reports how many members fall in a range without transferring them at all — the cheapest way to answer "how many?" without pulling data you don’t need.
How It Works Step by Step
When Redis receives ZRANGEBYSCORE leaderboard:global 150 300, here’s what happens internally:
- The single command-processing thread looks up
leaderboard:globalin the keyspace and confirms it holds a sorted set (any other type triggers aWRONGTYPEerror immediately). - Redis descends through the skip list’s levels, using the higher levels to skip large chunks of the ordered list at once, homing in on the first node with a score >= 150 in roughly O(log N) steps.
- Starting from that node, Redis walks forward one node at a time along the bottom level, collecting members whose score is <= 300, and stops as soon as it passes 300.
- If
LIMIT offset countwas given, Redis skipsoffsetmatches from the start of that walk before it starts collecting, then stops aftercountmatches. IfWITHSCORESwas given, each score is serialized right after its member. - The collected members (and scores, if requested) are packed into a RESP array and sent back to the client in one reply — the whole operation completes before any other client’s command runs, since Redis is single-threaded.
Common Mistakes
Mistake 1: Passing min and max in the wrong order
Unlike ZRANGE, which takes rank/index positions, ZRANGEBYSCORE always expects min before max as score bounds. Pass them backwards and Redis doesn’t error — it silently returns an empty array, which can be a confusing bug to track down.
ZADD scores:demo 10 "x"
ZADD scores:demo 20 "y"
ZADD scores:demo 30 "z"
ZRANGEBYSCORE scores:demo 30 10
ZRANGEBYSCORE scores:demo 10 30
Output:
(integer) 1
(integer) 1
(integer) 1
(empty array)
1) "x"
2) "y"
3) "z"
The first query (min=30, max=10) matches nothing because no score can simultaneously be >= 30 and <= 10. Swapping the arguments to 10 30 fixes it. When in doubt about direction, remember: lower bound first, always.
Mistake 2: Running ZRANGEBYSCORE on the wrong type
Every Redis key has exactly one data type. Calling a sorted-set command on a key created with SET fails with WRONGTYPE.
SET session:abc123 "active"
ZRANGEBYSCORE session:abc123 0 100
Output:
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value
There’s no implicit conversion between types in Redis. If you need both a plain value and a sorted-set-style index for the same entity, use two different keys (e.g. session:abc123 for the string and session:abc123:events for a zset), not one key doing double duty.
Mistake 3: Forgetting exclusive bounds when paginating adjacent ranges
If you split a large range into pages using inclusive bounds on both ends, the member sitting exactly on a page boundary gets returned twice.
ZADD events:page 100 "e1"
ZADD events:page 200 "e2"
ZADD events:page 300 "e3"
ZRANGEBYSCORE events:page 100 200
ZRANGEBYSCORE events:page 200 300
ZRANGEBYSCORE events:page (200 300
Output:
(integer) 1
(integer) 1
(integer) 1
1) "e1"
2) "e2"
1) "e2"
2) "e3"
1) "e3"
The first two queries both include the score-200 member (e2), duplicating it across pages. Making the second page’s lower bound exclusive with (200 fixes it — the third query correctly returns only e3.
Best Practices
- Use
-infand+inffor open-ended bounds instead of guessing a very large or very negative number. - When paginating adjacent score ranges, make one end of each boundary exclusive with
(to avoid returning the same member on two pages. - Use
LIMIT offset countto page through wide ranges rather than fetching everything and slicing it client-side. - Use
ZCOUNTinstead ofZRANGEBYSCOREplus counting on the client when you only need a count — it skips the data-transfer cost entirely. - Check
ZCARDbefore running an unbounded-inf/+infquery on a sorted set you don’t fully control the size of — a huge result set blocks the single-threaded server while it’s assembled. - Keep scores as genuine, meaningful floats (timestamps, prices, ranks) rather than packing composite or non-numeric data into them.
- Prefer the newer
ZRANGE key min max BYSCOREform for new code if your Redis version and client library support it (6.2+); keep usingZRANGEBYSCOREfreely in existing code — it isn’t going away.
Practice Exercises
- Build a sorted set called
leaderboard:weeklywith three players and scores of your choosing. Write a singleZRANGEBYSCOREcommand that returns only players scoring strictly more than 500. Hint: you’ll need an exclusive lower bound. - Build a sorted set called
prices:electronicswhere members are product keys and scores are prices. Write one command that returns just the second- and third-cheapest products priced between $0 and $1000, skipping the very cheapest one. Hint: think about which optional clause skips results. - Build a sorted set called
pageviews:2026-08-10where members are page identifiers and scores are Unix timestamps. Write a command that counts (without transferring the members themselves) how many events fall in a given hour-long window, then a second command that returns those same events with their scores visible.
Summary
ZRANGEBYSCORE key min maxreturns sorted set members whose score falls within[min, max], in ascending score order, in O(log(N)+M) time.- Sorted sets are backed by a skip list (for ordered range access) plus a hash table (for O(1) score lookups), or a compact listpack when the set is small.
- Bounds are inclusive by default; prefix with
(for exclusive, or use-inf/+inffor open-ended ranges. WITHSCORESincludes each member’s score in the reply;LIMIT offset countpaginates within the matched range.- Passing
mingreater thanmaxdoesn’t error — it silently returns an empty result, a common source of bugs. - Unbounded range queries on a large sorted set block the single-threaded server, just like
KEYS *does across the keyspace — scope your ranges or useSCAN-style incremental patterns where possible. ZREVRANGEBYSCOREand the modernZRANGE ... BYSCORE [REV]form cover descending-order queries with the same cost profile.
