SET & GET
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, on an Apple-silicon Mac. The# cli/...transcripts form one session in document order and are replayed by the course harness; aTTLyou read a moment after aSET ... EXmay be one second lower than the value you set (see the Expiration lesson for the rounding rule).
SET and GET — the core of Redis strings
Section titled “SET and GET — the core of Redis strings”SET key value stores a string. GET key retrieves it. If the key already exists, SET overwrites it unconditionally (unless you add a condition flag).
127.0.0.1:6379> SET city "Bangkok"OK127.0.0.1:6379> GET city"Bangkok"127.0.0.1:6379> SET city "Chiang Mai"OK127.0.0.1:6379> GET city"Chiang Mai"A Redis string is binary-safe — it is a byte array, not text. It can hold JSON, a serialized protobuf, an image, or 0x00 bytes, up to 512 MB (the proto-max-bulk-len setting; Redis strings docs). Writing past that limit is refused; the check is cheap to prove without allocating half a gigabyte:
127.0.0.1:6379> SETRANGE big 536870912 x(error) ERR string exceeds maximum allowed size (proto-max-bulk-len)127.0.0.1:6379> CONFIG GET proto-max-bulk-len1) "proto-max-bulk-len"2) "536870912"STRLEN counts bytes, not characters: SET thai "สวัสดี" (six Thai characters) followed by STRLEN thai gives 18 (three UTF-8 bytes each).
SET options
Section titled “SET options”SET supports several options in one command:
| Option | Meaning |
|---|---|
EX seconds | Set expiration in seconds |
PX milliseconds | Set expiration in milliseconds |
EXAT unix-time-seconds | Expire at a Unix timestamp (seconds) |
PXAT unix-time-ms | Expire at a Unix timestamp (milliseconds) |
NX | Only set if the key does not exist |
XX | Only set if the key does exist |
GET | Return the old value before overwriting |
KEEPTTL | Retain the existing TTL instead of resetting it |
IFEQ value (8.4+) | Only set if the current value equals value (also IFNE, IFDEQ, IFDNE) |
127.0.0.1:6379> SET token "abc123" EX 60OK127.0.0.1:6379> TTL token(integer) 60127.0.0.1:6379> SET counter 0 NXOK127.0.0.1:6379> SET counter 99 NX(nil)127.0.0.1:6379> GET counter"0"127.0.0.1:6379> SET city "Phuket" GET"Chiang Mai"127.0.0.1:6379> GET city"Phuket"127.0.0.1:6379> SET city "Phuket" NX GET"Phuket"127.0.0.1:6379> SET fresh 1 XX(nil)Read the results: NX on an existing key returns (nil) and changes nothing; SET ... GET hands you the old value while writing the new one (one atomic step, so no other client can slip in between); NX combined with GET (allowed since 7.0) returns the current value and leaves it alone; XX on a key that does not exist is a no-op. TTL right after EX 60 shows the real 60 — if you read it a second later it shows 59, the value is rounded, not “N minus one”.
SET token "abc123" EX 60
TTL token
SET counter 0 NX
SET counter 99 NX
GET counter
SET city "Phuket" GET
GET cityA plain SET erases the TTL — KEEPTTL keeps it
Section titled “A plain SET erases the TTL — KEEPTTL keeps it”127.0.0.1:6379> SET token "abc999" KEEPTTLOK127.0.0.1:6379> TTL token(integer) 59127.0.0.1:6379> SET token "abc000"OK127.0.0.1:6379> TTL token(integer) -1The first SET kept the countdown (59 s left). The second, plain SET replaced the key and its expiry went to -1: the key is now permanent. This is the most common silent leak in cache code.
Read-modify variants: GETEX, GETDEL, LCS
Section titled “Read-modify variants: GETEX, GETDEL, LCS”Three more single-key string commands remove a round trip or a race:
127.0.0.1:6379> GETEX token"abc000"127.0.0.1:6379> SET token "again" EX 100OK127.0.0.1:6379> GETEX token PERSIST"again"127.0.0.1:6379> TTL token(integer) -1127.0.0.1:6379> GETDEL token"again"127.0.0.1:6379> GET token(nil)127.0.0.1:6379> SET key1 ohmytextOK127.0.0.1:6379> SET key2 mynewtextOK127.0.0.1:6379> LCS key1 key2"mytext"127.0.0.1:6379> LCS key1 key2 LEN(integer) 6GETEX reads a value and changes its expiry in one step (EX, PX, EXAT, PXAT or PERSIST — the sliding-session primitive). GETDEL reads and deletes atomically — a one-time token. LCS returns the longest common subsequence of two string values on the server (6.0+).
Compare-and-set with IFEQ and DELEX (Redis 8.4+)
Section titled “Compare-and-set with IFEQ and DELEX (Redis 8.4+)”Until 8.4, “change this key only if it still holds the value I read” needed WATCH or a Lua script. On Redis 8.4 and later it is a flag:
127.0.0.1:6379> SET v1 helloOK127.0.0.1:6379> SET v1 world IFEQ helloOK127.0.0.1:6379> GET v1"world"127.0.0.1:6379> SET v1 nope IFEQ hello(nil)127.0.0.1:6379> DELEX v1 IFEQ nope(integer) 0127.0.0.1:6379> DELEX v1 IFEQ world(integer) 1127.0.0.1:6379> EXISTS v1(integer) 0IFEQ with a stale expected value returns (nil) and leaves the key alone; DELEX ... IFEQ deletes only on a match (DELEX docs). Not available on Redis 8.0-8.2 or on Valkey — the Locks lesson keeps the Lua fallback.
SETEX and SETNX
Section titled “SETEX and SETNX”These are older convenience aliases — SET with EX or NX is preferred in modern Redis (options arrived in Redis 2.6.12), but you will still see them in legacy code:
127.0.0.1:6379> SETEX session:user1 3600 "token-xyz"OK127.0.0.1:6379> TTL session:user1(integer) 3600127.0.0.1:6379> SETNX lock:job1 "worker-A"(integer) 1127.0.0.1:6379> SETNX lock:job1 "worker-B"(integer) 0127.0.0.1:6379> GET lock:job1"worker-A"SETEX session:user1 3600 "token-xyz"
TTL session:user1
SETNX lock:job1 "worker-A"
SETNX lock:job1 "worker-B"
GET lock:job1MSET and MGET — batch operations
Section titled “MSET and MGET — batch operations”Set or get many keys in a single round-trip. This is significantly faster than individual commands when you need multiple keys at once.
127.0.0.1:6379> MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"OK127.0.0.1:6379> MGET user:1:name user:1:lang user:2:name user:2:lang1) "Ada"2) "Python"3) "Bob"4) "Go"127.0.0.1:6379> MGET user:1:name user:99:name1) "Ada"2) (nil)MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"
MGET user:1:name user:1:lang user:2:name user:2:lang
MGET user:1:name user:99:nameMSET really is atomic
Section titled “MSET really is atomic”The official docs say “MSET is atomic, so all given keys are set at once” (MSET). A test makes that concrete: one connection flips two keys 20,000 times while another reads both with MGET in a tight loop and counts how often it sees them differ.
// @runimport { Redis } from 'ioredis';
const writer = new Redis(process.env.REDIS_URL!);const reader = new Redis(process.env.REDIS_URL!);await writer.mset('b1:a', 'x', 'b1:b', 'x');
let stop = false;let torn = { mset: 0, twoSets: 0 };let reads = 0;const read = (async () => { while (!stop) { const [a, b] = await reader.mget('b1:a', 'b1:b'); reads++; if (a !== b) torn.mset++; }})();
// writer flips both keys 20000 times: once with MSET (one command)...for (let i = 0; i < 20000; i++) await writer.mset('b1:a', String(i), 'b1:b', String(i));stop = true; await read;console.log(`MSET writer: ${reads} MGET reads, ${torn.mset} saw a != b`);
// ...and once with two separate SETs (two commands)stop = false; reads = 0;const read2 = (async () => { while (!stop) { const [a, b] = await reader.mget('b1:a', 'b1:b'); reads++; if (a !== b) torn.twoSets++; }})();for (let i = 0; i < 20000; i++) { await Promise.all([writer.set('b1:a', `s${i}`), writer.set('b1:b', `s${i}`)]);}stop = true; await read2;console.log(`two SETs writer: ${reads} MGET reads, ${torn.twoSets} saw a != b`);
await writer.del('b1:a', 'b1:b');writer.disconnect(); reader.disconnect();Real run: MSET writer: 19997 MGET reads, 0 saw a != b and two SETs writer: 19991 MGET reads, 282 saw a != b (the counts differ run to run; the zero does not). MSET — like MGET — is one command on the single command thread, so no reader ever sees half of it. Two SETs are two turns and are visible half-applied. What MSET does not give you is a transaction across different commands (a SET plus an INCR): that needs MULTI/EXEC or a script.
APPEND and STRLEN
Section titled “APPEND and STRLEN”APPEND adds to the end of an existing string value (or creates the key if absent). STRLEN returns the byte length of the value.
127.0.0.1:6379> SET log "2024-01-01 boot"OK127.0.0.1:6379> APPEND log " | 2024-01-01 ready"(integer) 34127.0.0.1:6379> GET log"2024-01-01 boot | 2024-01-01 ready"127.0.0.1:6379> STRLEN log(integer) 34127.0.0.1:6379> SET greet hiOK127.0.0.1:6379> OBJECT ENCODING greet"embstr"127.0.0.1:6379> APPEND greet " there"(integer) 8127.0.0.1:6379> OBJECT ENCODING greet"raw"127.0.0.1:6379> SET num 42OK127.0.0.1:6379> OBJECT ENCODING num"int"The encoding is an implementation detail that tells you how Redis stores the string: numbers that fit a 64-bit integer are int, short strings are embstr (allocated together with the key object), and a string that has been modified in place — or is long — is raw. You do not choose it; you only need to know that APPEND on a hot key turns it into a raw string.
SET log "2024-01-01 boot"
APPEND log " | 2024-01-01 ready"
GET log
STRLEN logFrom a Node.js app
Section titled “From a Node.js app”The same commands through ioredis. Note that SET ... EX 30 NX returns 'OK' when it set the key and null when it did not, and that binary values round-trip through Buffer:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.del('b1:lock', 'b1:blob');
// SET ... NX EX: 'OK' when it set the key, null when the key was already thereconsole.log(await redis.set('b1:lock', 'worker-A', 'EX', 30, 'NX')); // "OK"console.log(await redis.set('b1:lock', 'worker-B', 'EX', 30, 'NX')); // nullconsole.log(await redis.get('b1:lock')); // "worker-A"
// strings are binary-safe: store raw bytes, read them back as a Bufferconst bytes = Buffer.from([0x00, 0xff, 0x10, 0x80]);await redis.set('b1:blob', bytes);const back = await redis.getBuffer('b1:blob');console.log(back?.equals(bytes), await redis.strlen('b1:blob')); // true 4
await redis.del('b1:lock', 'b1:blob');await redis.quit();Output: OK, null, worker-A, true 4. Always check the null — ignoring the return of a conditional SET is how two workers both think they hold the lock.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes “refresh the session data” for a login flow that stores sessions for 30 minutes:
🐛 Spot the bug
// login: session lives 30 minutesawait redis.set('session:42', JSON.stringify({ cart: [] }), 'EX', 1800);
// later, on every request that changes the cart:async function saveCart(cart: string[]) {await redis.set('session:42', JSON.stringify({ cart }));}Show the answer
A plain SET replaces the value and clears the expiry. After the first saveCart, TTL session:42 is -1 and the session never expires: we ran exactly this and got after plain SET: -1. Every touched session becomes a permanent key, and memory grows until maxmemory. Choose deliberately: KEEPTTL to update the data without touching the countdown, or a fresh EX for a sliding session.
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:session:42';
await redis.set(key, JSON.stringify({ cart: [] }), 'EX', 1800);
// the buggy version: a plain SET drops the TTLawait redis.set(key, JSON.stringify({ cart: ['book'] }));console.log('after plain SET:', await redis.ttl(key)); // -1 (persistent!)
// the fix when you want to keep the existing expiryawait redis.set(key, JSON.stringify({ cart: [] }), 'EX', 1800);await redis.set(key, JSON.stringify({ cart: ['book'] }), 'KEEPTTL');console.log('after SET KEEPTTL:', await redis.ttl(key)); // 1800 (or 1799 a moment later)
await redis.del(key);await redis.quit();The fixed version printed after SET KEEPTTL: 1800.
Mental Model
Section titled “Mental Model”A Redis string is a labelled box of bytes plus an optional countdown. SET replaces the whole box and, unless you say KEEPTTL, the countdown. The flags (NX, XX, GET, IFEQ) turn SET into a one-step “check, then act” — the check and the act happen on the single command thread, so nothing can slip between them. Whenever you catch yourself writing GET, think, SET, ask whether one of these flags already does it atomically.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
SET key val NX (conditional set) | Atomic check-then-set in one command; enough for a basic lock/dedup | No ownership token built in — you must design that yourself for safe unlocking |
MSET/MGET (batch operations) | Atomic across all the keys, and one round trip | The whole batch blocks the command thread for its duration (keep batches reasonable); no isolation from other commands — use MULTI/EXEC or a script for that |
SET ... GET / GETEX / GETDEL / IFEQ | Removes read-then-write races without Lua | Newer flags need a recent server (GETEX/GETDEL 6.2, IFEQ/DELEX 8.4) — not on older Redis or Valkey |
Plain unconditional SET key val | Simple, overwrites immediately with no pre-check | Overwrites another client’s data if key names collide, and silently clears any TTL |
Common Misconceptions
Section titled “Common Misconceptions”- “
MSETis not atomic.” It is; the test above saw 0 torn reads in ~20,000. What it is not is a multi-command transaction. - “A Redis string is text.” It is bytes;
STRLENcounts bytes (สวัสดีis 18), and the limit is 512 MB. - “
SETonly changes the value.” It also removes the TTL unlessKEEPTTLis given. - “
SET ... NXreturns 1 or 0.” That isSETNX.SET ... NXreturnsOKor(nil)(nullin ioredis).
Common mistakes
Section titled “Common mistakes”- Using a plain
SETwhen you meant “set only if it doesn’t exist” — forgettingNXsilently overwrites existing data. UseSET key val NXwhen the logic needs an atomic check-then-set. - Forgetting that a plain
SETresets a key’s existing TTL — if you want to keep the existing expiry while updating the value, addKEEPTTL; otherwise the key becomes persistent again. - Mixing the legacy
SETNX/SETEXcommands withSET ... NX/EXin the same codebase — this produces inconsistent style. Standardize onSETwith options, available since Redis 2.6.12.
Real-world example
Stripe’s public API documents an idempotency-key algorithm: a client sends an
Idempotency-Keyheader, the server saves the result of the first request under that key, and replays the same result for any retry with the same key within 24 hours (Idempotent requests, Stripe Docs). A key that succeeds exactly once is whatSET key val NX EX <ttl>gives you in Redis: the request that gotOKis the one that actually runs.