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
  1. SET hashes the key, inserts “v1” into the keyspace hash table, no TTL attached.
  2. EXPIRE attaches a separate expiration timestamp (current time + 100s) to the key, tracked in Redis’s internal expires table.
  3. TTL looks up that timestamp and reports 100 seconds remaining.
  4. The plain SET cache:homepage "v2" replaces the value and implicitly removes the expiration entry — this is why the next TTL call returns -1, even though nothing asked for the TTL to be removed.
  5. After re-attaching a 100-second TTL, using SET ... KEEPTTL updates the value without touching the expiration, so the final TTL still 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 with SCAN.
  • 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 ... KEEPTTL when refreshing a cached value in place if you want the original expiration to keep counting down.
  • Never run KEYS against a production dataset of nontrivial size — always prefer SCAN for iteration in application or operational code.
  • Check TYPE key before running type-specific commands against keys whose origin you’re not sure of, to avoid WRONGTYPE errors in production paths.
  • Remember TTL returning -2 means the key doesn’t exist, while -1 means it exists but has no expiration — don’t conflate the two when debugging.
  • Use DBSIZE for a fast, O(1) count of keys instead of counting the output of KEYS *.

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.
  • TTL returns -1 for “no expiration set” and -2 for “key doesn’t exist” — these are different states.
  • A plain SET clears any existing TTL; use SET ... KEEPTTL to preserve it.
  • KEYS blocks the whole server while scanning everything; SCAN iterates 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 WRONGTYPE error instead of silently succeeding.