Using Sets for Tags and Unique Tracking

A Redis set is an unordered collection of unique strings. There is no duplicate checking to do yourself and no ordering to maintain — Redis guarantees uniqueness for you, in O(1) time per element. This makes sets the natural fit for two extremely common problems: attaching a handful of tags to a record (an article, a product, a user) and counting or checking unique occurrences of something, like distinct visitors to a page on a given day. In this lesson you’ll learn the core set commands, how they work internally, and how to combine multiple sets to answer questions like "which tags do these two articles have in common?"

Overview / How it works

A set in Redis holds any number of unique string members, with no guarantee about the order they come back in. Internally, Redis uses one of two encodings for a set, and it switches automatically based on the data: a compact intset encoding is used when every member is an integer and the set is small (governed by set-max-intset-entries), and a hashtable encoding is used otherwise (including for any set containing non-integer strings, or once the set grows past the small-set thresholds). The hashtable encoding is what gives set operations their O(1) average time complexity for adding, removing, and checking membership — Redis simply hashes the member and looks up (or inserts into) a bucket, the same way an in-memory hash map works in any language.

Because membership checks are O(1), sets are the right structure whenever your access pattern is "does this value exist in this collection?" or "how many unique values are in this collection?" — not "give me these values in a specific order" (that’s a sorted set, covered elsewhere in this course) and not "give me these values in insertion order" (that’s a list). Tags fit sets perfectly: an article either has the tag redis or it doesn’t, and you never need tags 1 through 3 specifically before tag 4. Unique tracking fits sets perfectly too: you don’t care in what order visitors arrived, only whether a given visitor has already been counted today.

Every Redis key has exactly one type. A key holding a set can only be operated on with set commands (SADD, SREM, SMEMBERS, and so on); calling a set command on a key that holds a string, list, or hash returns a WRONGTYPE error rather than silently doing something unexpected. You’ll see this demonstrated in the Common Mistakes section below.

Syntax

The core commands you’ll use for tagging and unique tracking:

Command Purpose Time complexity
SADD key member [member ...] Add one or more members to a set (creates the set if it doesn’t exist) O(1) per member added
SREM key member [member ...] Remove one or more members from a set O(1) per member removed
SISMEMBER key member Check whether a single member exists in the set O(1)
SMISMEMBER key member [member ...] Check multiple members at once, returns an array of 0/1 O(N) for N members checked
SCARD key Return the number of members (the set’s cardinality) O(1)
SMEMBERS key Return every member of the set O(N) for N members in the set
SINTER key [key ...] Return members present in all given sets O(N*M), N = smallest set size, M = number of sets
SUNION key [key ...] Return members present in any given set O(N), N = total members across all sets
SDIFF key [key ...] Return members in the first set that are not in the others O(N), N = total members across all sets
SINTERCARD numkeys key [key ...] [LIMIT limit] Return only the count of the intersection, without transferring members O(N*M), same as SINTER

Each of these takes a key name (or names) and, for SADD/SREM/SISMEMBER/SMISMEMBER, one or more member strings. Member strings can be anything — a username, a tag word, a numeric ID as a string — Redis stores them as plain strings regardless.

Examples

Example 1: Tagging an article

Attach tags to a blog post and check membership:

SADD article:101:tags "redis" "database" "nosql" "caching"
SMEMBERS article:101:tags
SISMEMBER article:101:tags "redis"
SISMEMBER article:101:tags "python"
SCARD article:101:tags

Output:

(integer) 4
1) "caching"
2) "nosql"
3) "database"
4) "redis"
(integer) 1
(integer) 0
(integer) 4

The first SADD returns 4 because all four tags were new. SMEMBERS returns all four, but notice the order does not match insertion order — sets are unordered, and the exact order you see depends on the internal hashtable layout and can even change between reads on some operations. SISMEMBER confirms redis is present (1) and python is not (0). SCARD gives the tag count in O(1) without transferring any members, which is cheap even on a set with millions of entries.

Example 2: Tracking unique daily visitors

A very common Redis pattern: one set per day, one member per unique visitor.

SADD visitors:2026-08-10 "user:501" "user:502" "user:503"
SADD visitors:2026-08-10 "user:501"
SCARD visitors:2026-08-10
SISMEMBER visitors:2026-08-10 "user:502"
EXPIRE visitors:2026-08-10 86400
TTL visitors:2026-08-10

Output:

(integer) 3
(integer) 0
(integer) 3
(integer) 1
(integer) 1
(integer) 86400

The first SADD adds three new visitors and returns 3. The second call adds user:501 again, but since it’s already a member, nothing changes and Redis returns 0 — this is exactly why sets are perfect for "unique" tracking: your application can call SADD on every page view without worrying about double-counting, and SCARD always reflects the true unique count. We then set a 24-hour TTL with EXPIRE so this day’s tracking set cleans itself up automatically; TTL confirms 86400 seconds remain.

Example 3: Comparing tags across two articles

Sets really shine when you combine several of them:

SADD article:101:tags "redis" "database" "nosql" "caching"
SADD article:102:tags "redis" "database" "sql" "tutorial"
SINTER article:101:tags article:102:tags
SUNION article:101:tags article:102:tags
SDIFF article:101:tags article:102:tags
SINTERCARD 2 article:101:tags article:102:tags

Output:

(integer) 4
(integer) 4
1) "database"
2) "redis"
1) "caching"
2) "nosql"
3) "database"
4) "redis"
5) "sql"
6) "tutorial"
1) "nosql"
2) "caching"
(integer) 2

SINTER shows the tags shared by both articles (redis and database). SUNION shows every distinct tag across both articles combined (six total). SDIFF shows tags that belong to article 101 but not article 102 (nosql and caching) — order of the arguments matters for SDIFF, unlike SINTER/SUNION. Finally, SINTERCARD returns just the count of the intersection (2) without sending the actual tag strings back over the network, which matters when the intersection could be large and you only need the number, e.g. "how many mutual tags/interests do these two users share?"

How it works step by step

When you run SADD article:101:tags "redis", here’s what happens on the server, all within a single atomic step because Redis is single-threaded and executes one command to completion before starting the next:

  • Redis looks up the key article:101:tags. If it doesn’t exist, a new empty set object is created and stored under that key.
  • Redis checks whether every member being added, including this one, could use the compact intset encoding (all-integer values, under the configured size limit). Since "redis" is not an integer, the set uses (or converts to) the hashtable encoding.
  • The member is hashed and inserted into the underlying hashtable. If it’s already present, the insert is a no-op for that member.
  • SADD returns the count of members that were actually new — not the total set size — which is why re-adding user:501 in Example 2 returned 0 instead of 1.

Because this entire sequence runs as one atomic command with no other client’s commands interleaved, you never get a lost update where two concurrent requests both try to add the same visitor and end up double counting — SADD is safe to call from many concurrent clients on the same key without any external locking.

Common Mistakes

Mistake 1: Checking membership before adding, “to avoid duplicates”

It’s tempting to write application code that calls SISMEMBER first and only calls SADD if the member isn’t already there. This is unnecessary — SADD already deduplicates for you — and worse, it introduces a race condition: two concurrent requests can both run SISMEMBER, both see "not present," and both proceed as if they were the one adding it for the first time. Just call SADD directly; it’s atomic and idempotent on its own.

Mistake 2: Using SADD on a key that already holds a different type

Every key has exactly one type. If a key was created with SET (a string), you cannot add set members to it:

SET article:103:tags "redis"
SADD article:103:tags "database"

Output:

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

The fix is to pick a key naming convention that avoids collisions between different data types for the same entity (e.g. article:103:tags for the set, never reused for a string), and if a collision does happen, delete the misused key with DEL before creating the set.

Mistake 3: Expecting SMEMBERS output in insertion order

As Example 1 showed, SMEMBERS does not preserve the order you called SADD in. If your UI needs to display tags alphabetically or in insertion order, sort them client-side, or use a sorted set (ZADD) if you need Redis itself to maintain an order.

Mistake 4: Scanning large keyspaces with KEYS instead of SCAN

If you need to find every daily visitor-tracking key (visitors:2026-08-01, visitors:2026-08-02, …), it’s tempting to run KEYS visitors:*. KEYS is O(N) over the entire keyspace and, because Redis is single-threaded, it blocks every other client until it finishes — on a production dataset with millions of keys this can stall your whole application for seconds. Use the cursor-based, non-blocking SCAN instead:

SADD article:101:tags "redis" "database" "nosql" "caching"
SREM article:101:tags "caching" "nosql"
SMEMBERS article:101:tags
SCAN 0 MATCH article:101:* COUNT 100

Output:

(integer) 4
(integer) 2
1) "database"
2) "redis"
1) "0"
2) 1) "article:101:tags"

SREM removed two tags and returned 2 (the count actually removed). SCAN returns a two-element reply: a cursor ("0" means iteration is complete) and a batch of matching keys, without ever blocking the server the way KEYS does. For a set that grows very large itself, prefer SSCAN key cursor to iterate its members incrementally instead of pulling everything at once with SMEMBERS.

Best Practices

  • Use a set when you need uniqueness with no ordering requirement; reach for a sorted set instead if you need ranking, scores, or range queries.
  • Always put a TTL on ephemeral tracking sets (daily/hourly unique visitor sets, one-time event dedup sets) with EXPIRE — otherwise they accumulate forever and quietly consume memory.
  • Batch multiple members into one SADD/SREM call instead of looping one command per member from your application, to cut round trips.
  • Use SINTERCARD instead of SINTER when you only need the size of an intersection, to avoid transferring potentially large member lists over the wire.
  • Prefer SCAN/SSCAN over KEYS/SMEMBERS when the keyspace or the set itself might be large, since the latter block the single-threaded server for the full O(N) duration.
  • Namespace tag and tracking keys clearly, e.g. article:{id}:tags and visitors:{date}, so key patterns stay predictable and safe to SCAN.
  • Don’t call SISMEMBER before SADD just to “check first” — it’s redundant and race-prone; let SADD‘s own atomicity do the deduplication.

Practice Exercises

  • Create a tag set for product:2002 with the tags electronics, sale, and featured. Confirm sale is present with SISMEMBER, then confirm the total tag count with SCARD.
  • Track unique users who completed a "signup" action today in one set and unique users who completed a "purchase" action today in another. Use SINTER to find users who did both, and set a 24-hour TTL on each tracking set.
  • Given two product tag sets that share some tags and differ on others, use SDIFF in both directions to find the tags unique to each product, and SUNION to build a combined tag cloud across both.

Summary

  • Sets store unique, unordered string members and are backed by a hashtable (or a compact intset for small all-integer sets), giving O(1) average time for add, remove, and membership checks.
  • SADD is atomic and idempotent — call it directly instead of checking membership first, which avoids an unnecessary round trip and a race condition.
  • SCARD gives you a unique count in O(1) without transferring any members, ideal for "how many unique X" metrics.
  • SINTER, SUNION, and SDIFF combine multiple sets to answer "shared," "combined," and "only in this one" questions; SINTERCARD gets you just the intersection size cheaply.
  • Every key has one type — mixing set commands with a key created by SET (or any other type) raises a WRONGTYPE error.
  • Always TTL ephemeral tracking sets, and always prefer SCAN/SSCAN over KEYS/SMEMBERS at scale, since Redis’s single-threaded design means a slow O(N) command blocks every other client.