Skip to content

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. Timings are server-side microseconds from SLOWLOG (threshold 0) on this machine: orders of magnitude, not benchmarks.

A Redis Set is a collection of unique strings with no defined order. Adding a member that already exists is a no-op, so nothing is ever duplicated. That makes Sets the natural fit for tags, “already seen” checks and any group where each value appears once.

SADD and SREM — adding and removing members

Section titled “SADD and SREM — adding and removing members”

SADD key member [member ...] adds one or more members and returns the count of new members (duplicates are not counted). SREM key member [member ...] removes members and returns how many were actually present.

cli/sets-sorted-sets-sets-1.redis
127.0.0.1:6379> SADD article:1:tags "redis" "database" "caching"
(integer) 3
127.0.0.1:6379> SADD article:1:tags "redis"
(integer) 0
127.0.0.1:6379> SREM article:1:tags "caching"
(integer) 1
127.0.0.1:6379> SREM article:1:tags "nosql"
(integer) 0
127.0.0.1:6379> SCARD article:1:tags
(integer) 2
127.0.0.1:6379> OBJECT ENCODING article:1:tags
"listpack"

SCARD returns the number of members without fetching them. Note the last line: a small set of strings is stored as a listpack, not a hash table. The next section shows what that means for ordering.

SADD article:1:tags "redis" "database" "caching"
SADD article:1:tags "redis"
SREM article:1:tags "caching"
SREM article:1:tags "nosql"

Three encodings, and the order you must not rely on

Section titled “Three encodings, and the order you must not rely on”

A set picks one of three internal encodings by what it holds. The order SMEMBERS returns follows from the encoding, and only the fact that there is no guaranteed order is part of the contract:

cli/sets-sorted-sets-sets-2.redis
127.0.0.1:6379> SADD s z a m
(integer) 3
127.0.0.1:6379> SMEMBERS s
1) "z"
2) "a"
3) "m"
127.0.0.1:6379> SADD n 30 10 20
(integer) 3
127.0.0.1:6379> SMEMBERS n
1) "10"
2) "20"
3) "30"
127.0.0.1:6379> OBJECT ENCODING n
"intset"
127.0.0.1:6379> SADD n x
(integer) 1
127.0.0.1:6379> OBJECT ENCODING n
"listpack"
127.0.0.1:6379> SMEMBERS n
1) "10"
2) "20"
3) "30"
4) "x"
EncodingWhen (8.10.2 defaults)What we saw
intsetonly integers, up to set-max-intset-entries (512)30 10 20 came back sorted: 10 20 30
listpacksmall, mixed members, up to set-max-listpack-entries (128)z a m came back in insertion order
hashtableanything biggera 600-member set is hashtable

Both of those orders are implementation details of this version, not promises: the moment the set grows to a hashtable the order is unrelated to insertion. Anything that needs order belongs in a List or a Sorted Set. (An earlier version of this lesson showed invented orders for SMEMBERS; the transcripts here are real output.)

SMEMBERS key returns every member; SCARD key returns the count. SMEMBERS is O(N) and is fine on a small set, not on a big one (measured below).

SADD user:42:interests "music" "hiking" "redis" "coffee"
SMEMBERS user:42:interests
SCARD user:42:interests

SISMEMBER and SMISMEMBER — membership check

Section titled “SISMEMBER and SMISMEMBER — membership check”

SISMEMBER key member returns 1 or 0 in O(1), regardless of set size. SMISMEMBER key member [member ...] (6.2+) checks several members in one round trip and returns one flag per member, in order:

cli/sets-sorted-sets-sets-3.redis
127.0.0.1:6379> SISMEMBER n 10
(integer) 1
127.0.0.1:6379> SMISMEMBER n 10 99 x
1) (integer) 1
2) (integer) 0
3) (integer) 1
SADD seen:urls "https://redis.io" "https://astro.build"
SISMEMBER seen:urls "https://redis.io"
SISMEMBER seen:urls "https://example.com"
SMISMEMBER seen:urls "https://redis.io" "https://example.com"

SRANDMEMBER key [count] returns random members without removing them; a negative count allows repeats and always returns exactly |count| items. SPOP key [count] removes and returns random members.

cli/sets-sorted-sets-sets-4.redis
127.0.0.1:6379> SRANDMEMBER n 2
1) "10"
2) "20"
127.0.0.1:6379> SRANDMEMBER n -5
1) "30"
2) "x"
3) "30"
4) "10"
5) "x"
127.0.0.1:6379> SPOP n 1
1) "x"
127.0.0.1:6379> SCARD n
(integer) 3

The picks are random, so your output will differ. Five items came back from a four-member set because of the negative count (30 and x repeat).

SADD prize:pool "alpha" "beta" "gamma" "delta" "epsilon"
SRANDMEMBER prize:pool 2
SCARD prize:pool
SPOP prize:pool 1
SCARD prize:pool

SINTERCARD — count an intersection without building it (7.0+)

Section titled “SINTERCARD — count an intersection without building it (7.0+)”

SINTERCARD numkeys key [key ...] [LIMIT n] returns the size of the intersection, and stops early at LIMIT. The full set-algebra story is in Set operations.

cli/sets-sorted-sets-sets-5.redis
127.0.0.1:6379> SADD a 1 2 3 4 5
(integer) 5
127.0.0.1:6379> SADD b 3 4 5 6
(integer) 4
127.0.0.1:6379> SINTERCARD 2 a b
(integer) 3
127.0.0.1:6379> SINTERCARD 2 a b LIMIT 2
(integer) 2

I built a 200,000-member set (user:1 .. user:200000, hashtable, 8.8 MB) and asked SLOWLOG what each command cost the server:

CommandServer timeReply
SISMEMBER big user:1500005 µs1 integer
SSCAN big 0 COUNT 10044 µsa cursor + about 100 members
SMEMBERS big11,116 µs (11 ms)200,000 members (2.1 MB of member text)

SMEMBERS freezes the command thread for 11 ms and floods the client; SSCAN returns a page at a time. On a small set SSCAN returns everything in one call:

cli/sets-sorted-sets-sets-6.redis
127.0.0.1:6379> SADD big m1 m2 m3
(integer) 3
127.0.0.1:6379> SSCAN big 0 MATCH m* COUNT 20
1) "0"
2) 1) "m1"
2) "m2"
3) "m3"

A cursor of 0 in the reply means the scan is complete. Like SCAN, SSCAN can return the same member twice, and COUNT is a hint, not a page size.

From Node.js: tags, dedupe and a cursor walk

Section titled “From Node.js: tags, dedupe and a cursor walk”

sadd returns the number of new members, so it doubles as “first time I have seen this?”. sscanStream wraps the cursor loop:

src/sets-sorted-sets-sets-tags.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
await redis.del('b2:post:1:tags', 'b2:seen');
// SADD returns how many members were NEW, so it doubles as "first time seen?"
console.log(await redis.sadd('b2:post:1:tags', 'redis', 'cache', 'redis')); // 2 (the duplicate is ignored)
console.log(await redis.sismember('b2:post:1:tags', 'redis')); // 1
console.log(await redis.smismember('b2:post:1:tags', 'redis', 'sql')); // [ 1, 0 ]
// dedupe: process an event only the first time its id is added
async function firstTime(id: string) {
return (await redis.sadd('b2:seen', id)) === 1;
}
console.log(await firstTime('evt-1'), await firstTime('evt-1')); // true false
// iterate a big set with a cursor instead of SMEMBERS
for (let i = 0; i < 1000; i++) await redis.sadd('b2:seen', `evt-${i}`);
let n = 0;
for await (const batch of redis.sscanStream('b2:seen', { count: 200 })) n += (batch as string[]).length;
console.log('scanned', n, 'members'); // 1000 (evt-1 was already there)
await redis.del('b2:post:1:tags', 'b2:seen');
await redis.quit();

Real output: 2, 1, [ 1, 0 ], true false, scanned 1000 members.

An assistant writes the “does this user follow tag X?” check for a set that holds every tag the platform has ever seen:

🐛 Spot the bug

async function hasTag(tag: string) {
const tags = await redis.smembers('tags:all'); // 200,000 members
return tags.includes(tag);
}
Show the answer

It works, and it downloads the whole set to answer a yes/no question. On a 200,000-member set SMEMBERS cost the server 11,116 µs and ships 2.1 MB of member text per call, against 5 µs and one integer for SISMEMBER. The array includes is also O(N) on the client. Ask the server the question you actually have:

async function hasTag(tag: string) {
return (await redis.sismember('tags:all', tag)) === 1;
}

The runnable sets-sorted-sets-sets-tags.ts above uses sismember/smismember, and sscanStream for the case where you really do need every member.

A Set is a bag with a membership test: “is X in it?” is constant time whatever the size, “give me everything” costs in proportion to the size, and “in what order?” has no answer. Design around the test (SISMEMBER, SADD’s return value), not around dumping the contents.

ChoiceBenefitCost
Set for tags / dedupeUniqueness with no existence check; SADD is its own upsert and tells you if it was newNo order (what you see is an encoding artifact); to sort or rank use a Sorted Set
SISMEMBER / SMISMEMBERO(1) per member, batched in one round tripOnly yes/no: metadata about the member lives in another key
SMEMBERSOne call for everything11 ms of frozen server and 2.1 MB of text at 200k members; use SSCAN
SPOP for random drawsAtomic sample-and-removeThe member is gone; log draws separately if you need history
  • “SMEMBERS returns members in a stable order.” For a small set it is insertion order, for integers sorted, for a big set effectively arbitrary. None of that is guaranteed.
  • “SADD returns the set size.” It returns how many members were new. Use SCARD for the size.
  • “SRANDMEMBER with a count returns that many distinct members.” Only a positive count is distinct; a negative count allows repeats and always returns |count| items.
  • “SSCAN returns each member exactly once.” It may return a member more than once; it guarantees only that members present for the whole scan appear.
  • Calling SMEMBERS on a big set in a request path — use SISMEMBER for a question and SSCAN for a walk.
  • Checking membership one call at a time — SMISMEMBER (or a pipeline) turns N round trips into one.
  • Using a Set when you need order — use a List, or a Sorted Set with a timestamp as the score.

Real-world example

Redis’s own documentation lists tagging and “unique items” as the standard uses of Sets and describes the three encodings and their limits in the Sets data-type page.

What does SADD return when you add a member that already exists?
Which command checks membership in O(1) without fetching all members?
What is the difference between SRANDMEMBER and SPOP?
You SADD 30, 10, 20 and SMEMBERS returns 10, 20, 30. What can you rely on?
You need to test 50 tags for membership in one set. What is the best single command?
SRANDMEMBER n -5 on a 4-member set returns...