A Redis sorted set stores unique byte-string members, each associated with a numeric score. Scores determine order; they need not be unique. This supports leaderboards and time-based indexes, but it does not make score values into automatic expiration deadlines. Model membership, ranking, and cleanup as distinct operations with explicit boundaries.
Members are unique; scores can tie
ZADD inserts a new member or updates an existing member’s score in the same command. Reusing a member name does not create a second entry. By default its reply counts newly added members, so zero can mean a successful score update. CH instead counts additions and updates that actually change a score.
NX restricts admission to new members, while XX restricts updates to existing members. GT allows a higher replacement score, but by itself still permits adding new members. Combine XX and GT when the contract is specifically to improve an existing member’s score. Check the returned result instead of assuming every syntactically valid request changed the leaderboard.
ZRANGE normally orders scores ascending. Equal scores are resolved by binary lexicographic member order, not insertion time or locale-aware collation. REV reverses both score order and tie order. Ranks start at zero, and rank-range endpoints are inclusive: positions zero through nine select at most ten members. ZRANK and ZREVRANK report positions in the corresponding direction.
Rank and score are different coordinates
A rank query selects positions; ZRANGE BYSCORE selects numeric bounds. Score bounds are inclusive unless prefixed with an opening parenthesis. When adding REV to a score-range query, supply the upper bound first. Neither form reserves membership or freezes later pages. A concurrent insertion can shift positions between two otherwise valid reads.
Scores use double-precision floating-point numbers. Integers throughout the inclusive range from negative two-to-the-fifty-third to positive two-to-the-fifty-third are represented exactly; beyond it, adjacent integers may collapse. Raw epoch nanoseconds are therefore unsuitable when every nanosecond must remain distinct in the score. Keep exact identifiers in member data and choose score units with an explicit precision requirement.
When it breaks
A score query only reads. Storing timestamps and reading a recent window does not delete older members or assign member TTLs. ZREMRANGEBYSCORE explicitly removes a score interval, while key expiration removes the entire set. Cleanup needs an application-owned trigger and a clearly chosen inclusive or exclusive cutoff; otherwise stale membership can grow without bound.
Sorted access is not constant-cost regardless of result size. ZRANGE has documented complexity O(log N + M), where M is returned members, and score-range removal also depends on how many members it removes. Large responses and bulk cleanup still consume time. Pagination is not a snapshot, and an atomic ZADD does not turn a separate read, decision, and write into one transaction.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The setup compiles Redis core with libc and installs its Python client in a temporary virtual environment. It does not install anything system-wide or build optional Redis modules.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It starts a disposable Redis using a private Unix socket under /tmp, with TCP, scheduled snapshots, and append-only persistence disabled. The program closes its client, stops only its own process, and removes its temporary runtime directory. Dependency downloads require network access; these assertions do not.
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
assert client.zadd("board", {"ada": 10, "ben": 20, "cy": 20}) == 3
assert client.zrange("board", 0, -1) == [b"ada", b"ben", b"cy"]
assert client.zrange("board", 0, 1, desc=True) == [b"cy", b"ben"]
assert client.zrank("board", "ada") == 0
assert client.zrevrank("board", "cy") == 0
print("rank starts at zero; REV reverses scores and bytewise tie order")
assert client.zadd("board", {"ada": 30}) == 0
assert client.zcard("board") == 3
assert client.zrange("board", 0, 0, desc=True) == [b"ada"]
assert client.zadd("board", {"ben": 25}, xx=True, gt=True, ch=True) == 1
assert client.zadd("board", {"ben": 24}, xx=True, gt=True, ch=True) == 0
assert client.zadd("board", {"new": 99}, xx=True, gt=True, ch=True) == 0
assert client.zscore("board", "ben") == 25
assert client.zscore("board", "new") is None
assert client.zadd("admission", {"new": 99}, gt=True) == 1
print("updates keep cardinality; XX GT CH gates updates; GT alone admits new members")
requested = 2**53 + 1
client.zadd("precision", {"event": requested})
observed = int(client.zscore("precision", "event"))
assert observed == 2**53 and observed != requested
print(f"score precision: requested {requested}, stored {observed}")
client.zadd("window", {"old": 100, "edge": 200, "fresh": 300})
assert client.zrange("window", 200, "+inf", byscore=True) == [
b"edge", b"fresh",
]
assert client.zrange("window", "(200", "+inf", byscore=True) == [b"fresh"]
assert client.zcard("window") == 3
assert client.ttl("window") == -1
assert client.zremrangebyscore("window", "-inf", "(200") == 1
assert client.zrange("window", 0, -1) == [b"edge", b"fresh"]
print("score queries do not expire members; explicit exclusive-bound cleanup removes old")
client.zadd("pages", {"a": 10, "b": 20, "c": 30})
first = client.zrange("pages", 0, 1)
client.zadd("pages", {"new": 5})
second = client.zrange("pages", 2, 3)
assert first == [b"a", b"b"] and second == [b"b", b"c"]
print("rank pagination is not a snapshot: inserting before page two repeats b")
The first results demonstrate zero-based ranks and reverse tie order. Updating Ada returns zero while moving that member to the top without increasing cardinality. XX GT CH accepts Ben’s increase, rejects a decrease and a missing member, while GT alone admits a new member in a separate set.
The precision assertion observes 9007199254740992 after requesting 9007199254740993. Inclusive and exclusive score queries return different boundaries but leave all three members stored; explicit removal deletes only the old member. Finally, inserting before the next rank page repeats member b. This deliberately controlled interleaving proves pagination can shift without claiming to be a concurrency benchmark. The experiment verifies ordering and command semantics, not production throughput, cleanup scheduling, or distributed consistency.
Sources
- Redis sorted sets, member uniqueness and ranking use cases, checked 2026-09-08.
- Redis ZADD, update conditions, return counts, and score precision, checked 2026-09-08.
- Redis ZRANGE, rank/score bounds, reverse tie order, and complexity, checked 2026-09-08.
- Redis ZREMRANGEBYSCORE, explicit removal and cutoff semantics, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.