Keys and the Keyspace
Every piece of data in Redis lives under a key — a binary-safe string that acts like a variable name pointing at a value (a string, hash, list, set, sorted set, and so on). The entire collection of keys inside one Redis logical database is called the keyspace. Understanding how the keyspace is organized, named, and expired is foundational: almost everything else in Redis — caching, sessions, rate limiting, leaderboards — is really just a discipline for naming and expiring keys well.
This lesson covers what a key actually is under the hood, how to inspect and manage the keyspace, how time-to-live (TTL) expiration works internally, and the mistakes that quietly cause outages or memory leaks in production Redis deployments.
Overview / How it works
Internally, a Redis database is a single large hash table mapping key names to value objects. When you run SET user:1001:name "Ada", Redis computes a hash of the key string, stores a pointer to a Redis object (the string “Ada”) in that hash table’s bucket, and returns OK. Every read or write starts with this same hash table lookup, which is why basic key operations like GET, SET, and EXISTS run in O(1) average time regardless of how many keys exist in the database.
A key is just bytes — it can contain spaces, binary data, or newlines — but by convention (and it really is just convention, Redis does not enforce this) developers use short, human-readable, colon-namespaced strings such as user:1001:name, session:abc123, or product:100:price. This convention groups related keys visually and makes pattern matching with SCAN or KEYS predictable. There is no concept of “folders” or nested namespaces in Redis — the colon is purely a human convention, not a structural feature.
Redis is single-threaded for command execution: one command runs to completion before the next one starts, even under heavy concurrent client load. This means every individual command — including compound ones like INCR or HSET with multiple fields — is atomic with respect to other clients. Nobody can observe a half-finished write. This is a big part of why Redis is a natural fit for counters, distributed locks, and rate limiters without needing separate locking logic.
Every key has exactly one data type at a time — string, list, hash, set, sorted set, stream, and a few others. Redis tracks this per key internally, and running a command meant for the wrong type (like a list command against a key holding a string) is rejected with a WRONGTYPE error rather than silently doing something unexpected.
Expiration (TTL)
Keys can optionally carry an expiration time (a TTL, or time-to-live) set via EXPIRE or as part of a SET ... EX call. Redis does not scan the whole keyspace on a timer to remove every expired key the instant it expires — that would be wasteful. Instead it uses two complementary mechanisms:
- Lazy expiration — whenever a key is accessed (via
GET,EXISTS, etc.), Redis first checks whether its TTL has passed. If so, it deletes the key on the spot and behaves as if the key never existed. - Active expiration — a background cycle periodically samples a small number of keys with TTLs set and proactively removes any that have expired, even if nobody reads them. This prevents expired-but-unread keys from sitting in memory forever.
A plain SET key value on an existing key clears any TTL that key previously had — the key becomes persistent again unless you explicitly pass the KEEPTTL option. This surprises a lot of people who expect a value update to leave the expiration untouched.
Syntax
The core commands for working with keys (as opposed to the values they point to) are:
EXISTS key [key ...]
DEL key [key ...]
TYPE key
EXPIRE key seconds
TTL key
PERSIST key
RENAME key newkey
KEYS pattern
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
RANDOMKEY
DBSIZE
| Command | Purpose | Time complexity |
|---|---|---|
EXISTS key |
Returns 1 if the key exists, 0 otherwise (accepts multiple keys, summing matches) | O(1) per key |
DEL key |
Removes one or more keys immediately, freeing their memory | O(1) per string key, O(M) for a key holding a collection of M elements |
TYPE key |
Returns the data type stored at the key: string, list, hash, set, zset, stream, or none | O(1) |
EXPIRE key seconds |
Sets a TTL in seconds on an existing key | O(1) |
TTL key |
Returns remaining seconds; -1 means no TTL set; -2 means the key doesn’t exist | O(1) |
PERSIST key |
Removes any TTL, making the key permanent again | O(1) |
RENAME key newkey |
Renames a key, overwriting newkey if it already exists |
O(1) |
KEYS pattern |
Returns ALL keys matching a glob pattern, scanning the entire keyspace in one blocking pass | O(N) — N is the total number of keys in the database |
SCAN cursor |
Iterates the keyspace incrementally in small batches without blocking the server | O(1) per call, O(N) to fully iterate |
RANDOMKEY |
Returns a random key from the current database | O(1) |
DBSIZE |
Returns the total number of keys in the current database | O(1) |
Examples
Example 1: Basic key operations
SET user:1001:name "Ada"
SET user:1001:email "ada@example.com"
EXISTS user:1001:name
TYPE user:1001:name
DEL user:1001:email
EXISTS user:1001:email
Output:
OK
OK
(integer) 1
string
(integer) 1
(integer) 0
Two keys are created, both holding plain strings. EXISTS confirms the first key is present, TYPE reports it’s a string, and after DEL removes the second key, a follow-up EXISTS correctly reports 0 — the key is gone.
Example 2: TTLs and expiration
SET session:abc123 "active"
EXPIRE session:abc123 60
TTL session:abc123
PERSIST session:abc123
TTL session:abc123
TTL session:doesnotexist
Output:
OK
(integer) 1
(integer) 60
(integer) 1
(integer) -1
(integer) -2
After setting a 60-second TTL, TTL reports 60 seconds remaining. PERSIST strips the TTL, so the next TTL call returns -1 (key exists, no expiration). The final call shows the other common result: -2, meaning the key doesn’t exist at all — a different situation from “exists but no TTL”, and one that’s easy to confuse.
Example 3: Listing keys with KEYS vs iterating with SCAN
SET product:100:name "Widget"
SET product:101:name "Gadget"
SET product:102:name "Gizmo"
KEYS product:*
SCAN 0 MATCH product:* COUNT 100
Output:
OK
OK
OK
1) "product:100:name"
2) "product:101:name"
3) "product:102:name"
1) "0"
2) 1) "product:100:name"
2) "product:101:name"
3) "product:102:name"
KEYS product:* returns all three matching keys in one shot, but it does so by walking the entire keyspace in a single blocking pass — on a database with millions of keys this can freeze every other client for seconds. SCAN 0 MATCH product:* COUNT 100 does the equivalent job cursor by cursor: it returns a new cursor (here "0", meaning iteration is complete since this dataset is small) plus a batch of matched keys, without ever blocking the single event loop for long.
How it works step by step
Walking through what happens when you run SET cache:homepage "v1" followed later by EXPIRE cache:homepage 100 and then another SET:
SET cache:homepage "v1"
EXPIRE cache:homepage 100
TTL cache:homepage
SET cache:homepage "v2"
TTL cache:homepage
EXPIRE cache:homepage 100
SET cache:homepage "v3" KEEPTTL
TTL cache:homepage
Output:
OK
(integer) 1
(integer) 100
OK
(integer) -1
(integer) 1
OK
(integer) 100
SEThashes the key, inserts “v1” into the keyspace hash table, no TTL attached.EXPIREattaches a separate expiration timestamp (current time + 100s) to the key, tracked in Redis’s internal expires table.TTLlooks up that timestamp and reports 100 seconds remaining.- The plain
SET cache:homepage "v2"replaces the value and implicitly removes the expiration entry — this is why the nextTTLcall returns-1, even though nothing asked for the TTL to be removed. - After re-attaching a 100-second TTL, using
SET ... KEEPTTLupdates the value without touching the expiration, so the finalTTLstill reports 100.
Common Mistakes
Mistake 1: Using KEYS * against a production database. Because KEYS blocks the single Redis thread until it has scanned every key, running KEYS * (or even a narrower pattern) on a database with millions of keys can stall every other client for the duration of the scan. Always use SCAN with MATCH and a reasonable COUNT in application code or scripts; reserve KEYS for ad-hoc debugging on small development datasets.
Mistake 2: Assuming a plain SET preserves an existing TTL. As shown above, SET key value silently clears any TTL on that key unless you add KEEPTTL. A cache-refresh routine that does SET without KEEPTTL (or without re-issuing EXPIRE) accidentally turns temporary cache entries into permanent ones, leaking memory over time.
Mistake 3: Assuming EXPIRE on a nonexistent key does something. Calling EXPIRE on a key that isn’t there returns (integer) 0 and has no effect — it does not create the key or raise an error. Always check the return value if the key’s existence isn’t already guaranteed.
Mistake 4: Using the wrong command for a key’s data type. Every key has exactly one type, and calling a command meant for another type fails immediately:
RPUSH mylist:tasks "task1"
GET mylist:tasks
Output:
(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value
mylist:tasks holds a list, so calling the string command GET against it fails with a WRONGTYPE error rather than returning garbage or coercing the type. Always use TYPE key to check when you’re unsure, especially when key names are shared across different parts of an application.
Best Practices
- Use a consistent colon-namespaced naming convention, e.g.
entity:id:field(user:1001:email), so keys are self-describing and easy to pattern-match withSCAN. - Set a TTL on anything that represents cache or session data — an untended key with no expiration lives forever and slowly consumes memory.
- Use
SET ... KEEPTTLwhen refreshing a cached value in place if you want the original expiration to keep counting down. - Never run
KEYSagainst a production dataset of nontrivial size — always preferSCANfor iteration in application or operational code. - Check
TYPE keybefore running type-specific commands against keys whose origin you’re not sure of, to avoidWRONGTYPEerrors in production paths. - Remember
TTLreturning-2means the key doesn’t exist, while-1means it exists but has no expiration — don’t conflate the two when debugging. - Use
DBSIZEfor a fast, O(1) count of keys instead of counting the output ofKEYS *.
Practice Exercises
Exercise 1: Create keys order:7001:status and order:7002:status, both set to "pending". Use DBSIZE to confirm the count, then use RENAME to change order:7001:status to order:7001:state. Verify the old name no longer exists and the new one does.
Exercise 2: Set a key otp:5551234 with an appropriate TTL representing a one-time code that should expire in 5 minutes. Confirm the TTL with TTL, then use PERSIST to simulate “upgrading” it to a permanent key, and confirm TTL now reports -1.
Exercise 3: Create three keys named invoice:2001:total, invoice:2002:total, and invoice:2003:total. Practice iterating them safely with SCAN 0 MATCH invoice:* instead of KEYS, and explain in your own words why this matters on a production instance with 50 million keys.
Summary
- A Redis key is a binary-safe string mapped to a single typed value inside a hash-table-backed keyspace, giving O(1) average lookup.
- Single-threaded execution makes every individual command atomic — no other command can interleave mid-operation.
- TTLs are enforced through both lazy expiration (checked on access) and active expiration (a background sweep) — expired keys don’t have to be read to eventually be reclaimed.
TTLreturns-1for “no expiration set” and-2for “key doesn’t exist” — these are different states.- A plain
SETclears any existing TTL; useSET ... KEEPTTLto preserve it. KEYSblocks the whole server while scanning everything;SCANiterates incrementally without blocking and is the production-safe choice.- Every key has exactly one data type, and using the wrong command against it raises a
WRONGTYPEerror instead of silently succeeding.
