SET and GET
SET and GET are the two most fundamental commands in Redis: SET stores a value under a key, and GET retrieves it back. Together they form the foundation of Redis’s string data type, which is used everywhere from simple caching to session storage, counters, and feature flags. Because Redis strings are binary-safe, a single key can hold anything from a short username to a serialized JSON blob to raw image bytes, and both SET and GET run in constant time no matter how large your dataset grows.
Overview: How SET and GET Work
A Redis string is a sequence of bytes, up to 512MB in size. Internally, Redis stores strings using one of three encodings depending on the content, chosen automatically: int for values that look like a 64-bit integer (so INCR and friends can operate on them without parsing), embstr for short strings of 44 bytes or fewer (stored in a single, compact memory allocation), and raw for longer strings (stored as a Simple Dynamic String, or SDS, with separate metadata and buffer allocations that make appending cheap). You don’t have to think about this while using SET and GET, but it explains why short numeric values are so cheap to store and why Redis can increment a counter without ever converting it to a full string first.
Every Redis key lives in a single main hash table (the “keyspace dict”) that maps key names to values. When you run SET key value, Redis creates or overwrites the entry for key in that hash table — an O(1) operation regardless of how many other keys exist. GET key performs the same O(1) hash lookup in reverse: find the entry, and return its value, or a nil reply if no such key exists.
A second, separate hash table (the “expires dict”) tracks which keys have a time-to-live. A plain SET is destructive by default: if the key already existed with a TTL, that TTL is removed and the key becomes persistent again, because SET fully replaces the key’s value and, conceptually, its whole lifecycle. If you want to update a value without disturbing its expiration, you must say so explicitly with the KEEPTTL option, or set the expiration atomically in the same call with EX/PX. Getting this wrong is one of the most common sources of “keys that never expire” bugs in production, covered below in Common Mistakes.
Redis executes commands on a single main thread, one at a time, to completion. This means a single SET or GET is always atomic — no other client’s command can interleave in the middle of it. That single-threaded guarantee is also why SET’s NX (only if the key doesn’t already exist) and XX (only if it does) options are so useful: they let you implement “check and set” logic — such as a distributed lock or a set-once flag — as one atomic round trip, instead of an unsafe GET followed by a separate SET.
Syntax
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
GET key
| Argument / Option | Meaning |
|---|---|
key |
The key name to store or read. |
value |
The string value to store. Can be any bytes, including numbers written as text. |
NX |
Only set the key if it does not already exist. Returns (nil) if the key exists and nothing is changed. |
XX |
Only set the key if it already exists. Returns (nil) if the key is missing. |
GET |
Return the key’s previous value (or (nil) if it had none) instead of OK, while still performing the set. |
EX seconds |
Set a TTL of seconds seconds, atomically, as part of the same command. |
PX milliseconds |
Same as EX but in milliseconds. |
EXAT / PXAT |
Set the key to expire at a specific Unix timestamp (seconds / milliseconds) rather than a relative offset. |
KEEPTTL |
Keep the key’s existing TTL instead of clearing it. Cannot be combined with EX/PX/EXAT/PXAT. |
Time complexity: both SET and GET are O(1) — constant time, regardless of the number of keys stored in the database.
Examples
Example 1: Basic SET and GET
SET user:1001:name "Ada Lovelace"
GET user:1001:name
GET user:1002:name
Output:
OK
"Ada Lovelace"
(nil)
The first SET stores the string under user:1001:name and replies OK. GET on that key returns the value as a quoted bulk string. GET on user:1002:name, a key that was never set, returns (nil) — not an error, and not an empty string, but a distinct null reply meaning “this key does not exist.”
Example 2: SET with a TTL, and why a plain SET clears it
SET session:abc123 "active" EX 60
TTL session:abc123
SET session:abc123 "updated"
TTL session:abc123
Output:
OK
(integer) 60
OK
(integer) -1
SET ... EX 60 stores the value and gives it a 60-second TTL in one atomic step. TTL confirms 60 seconds remain. But the second, plain SET overwrites the value and silently removes the TTL — the final TTL call returns -1, meaning the key now lives forever. This is expected Redis behavior, not a bug, but it surprises a lot of newcomers.
Example 3: Preserving TTL with KEEPTTL
SET session:xyz789 "active" EX 120
TTL session:xyz789
SET session:xyz789 "refreshed" KEEPTTL
TTL session:xyz789
Output:
OK
(integer) 120
OK
(integer) 120
Adding KEEPTTL to the second SET tells Redis to update the value but leave the existing expiration untouched (the exact number may read a second or two lower in practice, since a small amount of real time passes between the two TTL calls). This is the correct way to refresh a cached value without accidentally making it permanent.
Example 4: Atomic “set if not exists” for locks, and the GET option
SET lock:job42 "worker-1" NX EX 30
SET lock:job42 "worker-2" NX EX 30
GET lock:job42
Output:
OK
(nil)
"worker-1"
worker-1 claims the lock first: since lock:job42 doesn’t exist yet, NX allows the SET to proceed, and it also sets a 30-second safety TTL so the lock can’t be held forever if the worker crashes. worker-2‘s attempt fails — the key already exists, so NX blocks the write and returns (nil) instead of an error, leaving the original value untouched, as the final GET confirms.
SET counter:visits "10"
SET counter:visits "11" GET
Output:
OK
"10"
The GET option (available since Redis 6.2) turns SET into an atomic “swap” — it stores the new value and, instead of replying OK, returns whatever the key held immediately before the write. This removes the need for a separate GET-then-SET pair entirely.
How It Works, Step by Step
When the Redis server receives SET key value [options], it:
- Parses the command and validates the number and combination of arguments (an unknown flag or a nonsensical combination, like
NXtogether withXX, is rejected with an error before anything happens). - If
NXorXXwas given, checks whether the key currently exists in the main keyspace hash table. If the condition fails, the command stops here and replies(nil)(or, withGET, still returns the old value) — nothing is written. - Writes the new value into the keyspace hash table, replacing any previous entry for that key.
- Unless
KEEPTTLwas specified, removes any existing TTL for the key from the expires hash table, since the key is being treated as a brand-new value. - If
EX,PX,EXAT, orPXATwas given, computes the absolute expiration time and stores it in the expires hash table. - Replies
OK(or the old value, ifGETwas requested).
All of this happens as one uninterruptible unit of work on Redis’s single command-processing thread — no other client’s command can run in the middle.
When the server receives GET key, it looks up key in the keyspace hash table. If it’s absent, it replies (nil) immediately. If it’s present, Redis first checks — lazily — whether the key has an expiration time that has already passed. If so, Redis deletes the key on the spot and replies (nil), exactly as if the key had never existed; the stale value is never returned. If the TTL hasn’t passed (or there isn’t one), Redis returns the value. This lazy check is why expiration doesn’t require Redis to be constantly scanning every key — expired keys are cleaned up “on demand” when accessed. Redis also runs a background active expiration cycle that periodically samples a handful of keys with a TTL and deletes any that have expired, so memory used by expired keys that are never read again still gets reclaimed rather than sitting around forever.
Common Mistakes
1. Assuming a plain SET preserves a key’s TTL. As shown in Example 2, calling SET without KEEPTTL on a key that already has an expiration wipes that expiration out, turning a temporary cache entry or session into a permanent one. If your intent is to refresh a value while keeping its existing expiry, always add KEEPTTL, or reset the TTL explicitly with EX/PX in the same call.
2. Implementing “set if not exists” with a separate GET and SET instead of SET NX. A tempting but broken pattern looks like: run GET to check if a key is empty, and if it is, run SET. Between those two round trips, another client can run its own SET, and both clients will believe they “won” — a classic race condition. Because Redis is single-threaded, a single command is always atomic, but two separate commands never are. The fix is to let Redis do the check-and-set as one command:
SET inventory:sku100 "42"
SET inventory:sku100 "0" NX
Output:
OK
(nil)
The second SET ... NX correctly refuses to overwrite the existing value, atomically, with no window for a race — something a GET followed by a conditional SET can never guarantee.
3. Ignoring that every key has exactly one type, and mixing string and non-string commands on it. Once a key has been created by a list, hash, or set command, calling GET on it doesn’t quietly coerce types — it fails outright:
RPUSH mylist:demo "task-a"
GET mylist:demo
Output:
(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value
mylist:demo was created as a list by RPUSH, so GET — a string-only command — refuses to touch it and returns a WRONGTYPE error instead of silently returning something meaningless. Always make sure the command you’re using matches the data type the key was actually created with.
Best Practices
- Use colon-namespaced key names (
user:1001:name,session:abc123) so related keys are easy to scan, group, and reason about. - Set a TTL with
EX/PXat write time for anything cache-like, instead of writing the value and callingEXPIREseparately — it’s one atomic step instead of two, and it guarantees the key never accidentally ends up permanent if the second call is missed. - Use
KEEPTTLwhenever you’re refreshing a value in place and want its existing expiration to survive the update. - Prefer
SET ... NXover GET-then-SET whenever you need “only write if this doesn’t exist yet” semantics — it’s atomic and race-free. - Use the
GEToption onSETwhen you need the previous value as part of a write, instead of issuing a separateGETbeforehand. - Remember that Redis strings can hold up to 512MB, but that doesn’t mean they should — very large values slow down network transfer and memory allocation; consider whether a hash or a different structure fits the data better if you’re storing large composite objects.
- Never use
KEYSto find string keys by pattern in a production database — it’s O(N) and blocks the single-threaded server for the entire scan. UseSCAN, which is cursor-based and non-blocking, instead.
Practice Exercises
- Store your own name under the key
user:2002:name, then read it back withGET. Then tryGETting a key you never set, such asuser:2002:email, and confirm you get(nil)rather than an error. - Create a key
cache:page:homewith the value"rendered-html"and a 30-second TTL usingEX. Check its TTL withTTL. Then update its value usingKEEPTTLand confirm the TTL is still counting down rather than reset to-1. - Simulate two workers racing to claim a job: use
SET job:99 "worker-a" NXand thenSET job:99 "worker-b" NX. Predict which reply will beOKand which will be(nil)before you run them, then verify withGET job:99.
Summary
SET key valuestores a string and repliesOK;GET keyretrieves it or replies(nil)if the key doesn’t exist. Both are O(1).- A plain
SETclears any existing TTL on the key; useKEEPTTLto preserve it, or set a new one atomically withEX/PX/EXAT/PXAT. NXwrites only if the key doesn’t exist;XXwrites only if it does — both are atomic, unlike a manual GET-then-SET check.- The
GEToption onSETatomically returns the old value while writing the new one. - Every key has exactly one data type; running a string command on a key created by a different type (or vice versa) returns a
WRONGTYPEerror rather than coercing. - Redis’s single-threaded execution model makes each individual SET or GET atomic, which is the basis for safe locks and counters — but never assume atomicity across multiple separate commands.
