Redis Data Types Overview

Unlike a plain key-value store that only lets you save a blob of bytes under a key, Redis lets each key hold a specific data structure: a string, a list, a hash, a set, or a sorted set (plus more specialized types like streams, bitmaps, and HyperLogLogs covered in later lessons). Choosing the right data type for the job is the single most important skill in using Redis well, because each type comes with its own set of commands and its own performance characteristics. This lesson walks through the five foundational types, how Redis stores them internally, and when to reach for each one.

Overview / How It Works

Every key in Redis maps to exactly one value, and that value has exactly one type at any given moment. The type is set implicitly the first time you write to a key: running SET creates a string, running RPUSH creates a list, running HSET creates a hash, and so on. You can check a key’s type at any time with TYPE key. If you call a command meant for one type on a key that already holds a different type — for example, running a list command on a key that holds a string — Redis refuses with a WRONGTYPE error rather than silently doing the wrong thing.

Internally, Redis doesn’t store every list as the same in-memory structure regardless of size. It picks an encoding based on how large the value currently is. A small list, hash, or sorted set (few elements, short values) is stored as a compact, contiguous listpack — fast to scan and memory-efficient. As the structure grows past configured thresholds, Redis transparently converts it to a more scalable encoding: lists become a quicklist (a linked list of listpacks), hashes and sorted sets become full hash tables or skip lists, and small integer-only sets become an intset before upgrading to a hash table. You can inspect the current encoding of any key with OBJECT ENCODING key. This matters because it explains why Redis performance is so predictable: the documented time complexity of each command already accounts for which encoding applies at that size.

Because Redis is single-threaded for command execution, every individual command — no matter which data type it touches — runs to completion before the next command starts. There is no other thread that can interleave and see a value half-written. That’s why INCR is safe for counters used by many concurrent clients: the increment-and-store happens as one atomic step, with no possibility of two clients reading the same starting value and both writing the same result.

The Core Data Types at a Glance

Type Holds Key commands Typical use case
String Text, a number, or binary bytes SET, GET, INCR Caching, counters, flags
List An ordered sequence of strings RPUSH, LPOP, LRANGE Queues, recent-activity feeds
Hash Field-value pairs (like a small object) HSET, HGET, HGETALL Storing a record, e.g. a user profile
Set Unordered, unique strings SADD, SMEMBERS, SISMEMBER Tags, membership checks, deduplication
Sorted Set Unique strings each with a float score, kept in score order ZADD, ZRANGE, ZSCORE Leaderboards, priority queues, range queries

Syntax

Each type has its own family of commands, but they share a consistent shape: the command name, the key, then type-specific arguments.

SET key value [EX seconds|KEEPTTL]
GET key
RPUSH key value [value ...]
LRANGE key start stop
HSET key field value [field value ...]
HGETALL key
SADD key member [member ...]
SMEMBERS key
ZADD key score member [score member ...]
ZRANGE key start stop [WITHSCORES]
  • key — the namespaced key name, e.g. user:1001:name.
  • value / member — the string data being stored; sets and sorted sets require members to be unique within the key.
  • score — a double-precision float used to order members in a sorted set.
  • start / stop — zero-based indexes for lists and sorted sets; -1 means the last element, so 0 -1 means the whole collection.
  • EX seconds — attaches a TTL (time-to-live) to a string when it’s set.
  • KEEPTTL — tells SET to preserve any existing TTL instead of clearing it (the default behavior of a plain SET).

Examples

Example 1: Strings for values and counters

SET user:1001:name "Ada Lovelace"
GET user:1001:name
TYPE user:1001:name
SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
GET pageviews:home

Output:

OK
"Ada Lovelace"
string
OK
(integer) 1
(integer) 2
"2"

SET always replies OK on success. INCR parses the string as an integer, adds one, stores the result, and returns the new value as an integer reply — note that a plain GET afterward still returns it as a string, since Redis strings are just bytes with no separate numeric type.

Example 2: Lists as a queue

RPUSH queue:signup "user:1001"
RPUSH queue:signup "user:1002"
RPUSH queue:signup "user:1003"
LRANGE queue:signup 0 -1
LPOP queue:signup
LLEN queue:signup

Output:

(integer) 1
(integer) 2
(integer) 3
1) "user:1001"
2) "user:1002"
3) "user:1003"
"user:1001"
(integer) 2

RPUSH appends to the right end and returns the list’s new length each time. LPOP removes and returns from the left end — combining RPUSH with LPOP gives a classic FIFO queue, which is why lists are a common choice for simple job queues.

Example 3: Hashes for structured records

HSET user:1001 name "Ada Lovelace" email "ada@example.com" age 28
HGET user:1001 name
HGETALL user:1001
HINCRBY user:1001 age 1

Output:

(integer) 3
"Ada Lovelace"
1) "name"
2) "Ada Lovelace"
3) "email"
4) "ada@example.com"
5) "age"
6) "28"
(integer) 29

HSET returns the number of new fields created (not updated). A hash lets you store a whole record under one key and fetch a single field with HGET instead of pulling the entire object, which is far more efficient than stuffing JSON into a plain string when you only need one field.

Example 4: Sets for tags and membership

SADD tags:article:42 "redis" "database" "nosql"
SADD tags:article:42 "redis"
SMEMBERS tags:article:42
SISMEMBER tags:article:42 "redis"
SCARD tags:article:42

Output:

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

The second SADD adds "redis" again and correctly returns 0 new members, since sets silently ignore duplicates. Note that SMEMBERS does not guarantee any particular order — if you need order, you need a sorted set instead. SISMEMBER is an O(1) membership check, which is why sets are the right structure for “has this user already done X” logic.

Example 5: Sorted sets for leaderboards

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

Output:

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

ZRANGE ... WITHSCORES returns members lowest-score-first along with their scores as separate array entries. ZINCRBY atomically adjusts a member’s score and returns the new score. ZREVRANGE key 0 0 is the idiomatic way to fetch just the top entry — exactly what a “who’s in first place” leaderboard query needs.

How It Works Step by Step

When you run HSET user:1002 name "Alan" on a brand-new key, Redis: (1) checks that the key doesn’t already exist with a conflicting type, (2) allocates a new hash object using the compact listpack encoding since it’s small, (3) stores the field-value pair, and (4) returns the count of newly-created fields. As more fields are added and the hash grows past the configured size threshold, Redis automatically converts it to a full hash-table encoding — you never have to request this conversion yourself.

RPUSH queue:tiny "a" "b" "c"
OBJECT ENCODING queue:tiny
HSET user:1002 name "Alan"
OBJECT ENCODING user:1002

Output:

(integer) 3
"quicklist"
(integer) 1
"listpack"

For TTLs, expiration is not a background scan that constantly checks every key. Redis uses two complementary mechanisms: lazy expiration checks a key’s TTL the moment it’s accessed and deletes it on the spot if expired, and active expiration runs periodically in small batches, randomly sampling keys with a TTL set and removing any that have expired, even if nothing ever touches them again. This combination keeps memory from growing unbounded from forgotten expired keys while avoiding the cost of scanning the whole keyspace on every command.

Common Mistakes

Mistake 1: Assuming a plain SET preserves an existing TTL. It doesn’t — overwriting a key with SET clears any TTL unless you explicitly add KEEPTTL.

SET session:abc123 "active" EX 60
TTL session:abc123
SET session:abc123 "active-refreshed"
TTL session:abc123

Output:

OK
(integer) 60
OK
(integer) -1

The TTL silently vanishes after the second SET, turning a temporary session into a permanent key. Fix it with KEEPTTL:

SET session:abc123 "active" EX 60
SET session:abc123 "active-refreshed" KEEPTTL
TTL session:abc123

Output:

OK
OK
(integer) 60

Mistake 2: Calling a command against the wrong type. Every key has exactly one type, and Redis enforces it strictly.

SET session:abc123 "active"
LPUSH session:abc123 "extra"

Output:

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

This happens most often when a key name is reused across features without checking what already occupies it. Always confirm the intended type with TYPE key before writing to a key you didn’t just create.

Mistake 3: Reaching for KEYS to search the keyspace in production. KEYS pattern is O(N) on the total number of keys and, because Redis is single-threaded, it blocks every other client for the entire scan — on a database with millions of keys this can freeze your application for seconds. Use the cursor-based, non-blocking SCAN instead, which walks the keyspace in small increments across multiple calls.

SET key:demo:1 "a"
SET key:demo:2 "b"
SCAN 0

Output:

OK
OK
1) "0"
2) 1) "key:demo:1"
   2) "key:demo:2"

SCAN returns a cursor (here "0" means the iteration is complete) plus a batch of keys; on a large dataset you’d call it repeatedly, feeding each returned cursor back in, until it returns 0 again.

Best Practices

  • Pick the type that matches your access pattern, not the type that’s easiest to reach for — a JSON blob in a string means you must rewrite the whole value to change one field, where a hash lets you update just that field.
  • Always set a TTL on cache-style keys with EX/PX (or EXPIRE) so stale data can’t accumulate forever; remember EXPIRE on a key that doesn’t exist returns (integer) 0 and silently does nothing.
  • Use KEEPTTL when refreshing a value that must keep its existing expiration.
  • Prefer SCAN over KEYS for anything touching production traffic.
  • Check OBJECT ENCODING when tuning memory usage on large hashes, lists, or sorted sets — the encoding thresholds are configurable and can meaningfully change memory footprint.
  • Namespace key names with colons (entity:id:field) so related keys are easy to reason about and to target with SCAN MATCH patterns.
  • Lean on atomic single-command operations (INCR, ZINCRBY, HINCRBY) instead of a separate read-modify-write sequence, which is vulnerable to another client’s write landing in between your GET and your SET.

Practice Exercises

  • Model a shopping cart for user:2001 using a hash where each field is a product id and each value is a quantity; add three products, increment one product’s quantity, then fetch the whole cart with one command.
  • Build a simple “recently viewed products” list capped conceptually at the 5 most recent items for a user, using LPUSH to add new views to the front; check what LRANGE with indexes 0 4 would return.
  • Create a sorted set called leaderboard:weekly, add four players with different scores, and figure out which single command returns just the second-place player and their score.

Summary

  • Redis keys are typed: strings, lists, hashes, sets, and sorted sets each have their own dedicated commands.
  • Type mismatches produce a WRONGTYPE error rather than silently corrupting data.
  • Small collections use compact listpack/intset encodings that automatically upgrade as they grow — check with OBJECT ENCODING.
  • A plain SET clears any existing TTL; use KEEPTTL to preserve it.
  • Expiration combines lazy (on-access) and active (background sampling) deletion.
  • Use SCAN, never KEYS, against a production-sized keyspace.
  • Single-threaded execution makes every individual command atomic, which is why counters and increments are safe under concurrency without extra locking.