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:

  1. Parses the command and validates the number and combination of arguments (an unknown flag or a nonsensical combination, like NX together with XX, is rejected with an error before anything happens).
  2. If NX or XX was 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, with GET, still returns the old value) — nothing is written.
  3. Writes the new value into the keyspace hash table, replacing any previous entry for that key.
  4. Unless KEEPTTL was specified, removes any existing TTL for the key from the expires hash table, since the key is being treated as a brand-new value.
  5. If EX, PX, EXAT, or PXAT was given, computes the absolute expiration time and stores it in the expires hash table.
  6. Replies OK (or the old value, if GET was 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/PX at write time for anything cache-like, instead of writing the value and calling EXPIRE separately — 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 KEEPTTL whenever you’re refreshing a value in place and want its existing expiration to survive the update.
  • Prefer SET ... NX over GET-then-SET whenever you need “only write if this doesn’t exist yet” semantics — it’s atomic and race-free.
  • Use the GET option on SET when you need the previous value as part of a write, instead of issuing a separate GET beforehand.
  • 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 KEYS to find string keys by pattern in a production database — it’s O(N) and blocks the single-threaded server for the entire scan. Use SCAN, which is cursor-based and non-blocking, instead.

Practice Exercises

  1. Store your own name under the key user:2002:name, then read it back with GET. Then try GETting a key you never set, such as user:2002:email, and confirm you get (nil) rather than an error.
  2. Create a key cache:page:home with the value "rendered-html" and a 30-second TTL using EX. Check its TTL with TTL. Then update its value using KEEPTTL and confirm the TTL is still counting down rather than reset to -1.
  3. Simulate two workers racing to claim a job: use SET job:99 "worker-a" NX and then SET job:99 "worker-b" NX. Predict which reply will be OK and which will be (nil) before you run them, then verify with GET job:99.

Summary

  • SET key value stores a string and replies OK; GET key retrieves it or replies (nil) if the key doesn’t exist. Both are O(1).
  • A plain SET clears any existing TTL on the key; use KEEPTTL to preserve it, or set a new one atomically with EX/PX/EXAT/PXAT.
  • NX writes only if the key doesn’t exist; XX writes only if it does — both are atomic, unlike a manual GET-then-SET check.
  • The GET option on SET atomically 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 WRONGTYPE error 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.