Skip to content

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; a TTL you read a moment after a SET ... EX may be one second lower than the value you set (see the Expiration lesson for the rounding rule).

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).

cli/keys-strings-set-get-1.redis
127.0.0.1:6379> SET city "Bangkok"
OK
127.0.0.1:6379> GET city
"Bangkok"
127.0.0.1:6379> SET city "Chiang Mai"
OK
127.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:

cli/keys-strings-set-get-2.redis
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-len
1) "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 supports several options in one command:

OptionMeaning
EX secondsSet expiration in seconds
PX millisecondsSet expiration in milliseconds
EXAT unix-time-secondsExpire at a Unix timestamp (seconds)
PXAT unix-time-msExpire at a Unix timestamp (milliseconds)
NXOnly set if the key does not exist
XXOnly set if the key does exist
GETReturn the old value before overwriting
KEEPTTLRetain the existing TTL instead of resetting it
IFEQ value (8.4+)Only set if the current value equals value (also IFNE, IFDEQ, IFDNE)
cli/keys-strings-set-get-3.redis
127.0.0.1:6379> SET token "abc123" EX 60
OK
127.0.0.1:6379> TTL token
(integer) 60
127.0.0.1:6379> SET counter 0 NX
OK
127.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 city

A plain SET erases the TTL — KEEPTTL keeps it

Section titled “A plain SET erases the TTL — KEEPTTL keeps it”
cli/keys-strings-set-get-4.redis
127.0.0.1:6379> SET token "abc999" KEEPTTL
OK
127.0.0.1:6379> TTL token
(integer) 59
127.0.0.1:6379> SET token "abc000"
OK
127.0.0.1:6379> TTL token
(integer) -1

The 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.

Three more single-key string commands remove a round trip or a race:

cli/keys-strings-set-get-5.redis
127.0.0.1:6379> GETEX token
"abc000"
127.0.0.1:6379> SET token "again" EX 100
OK
127.0.0.1:6379> GETEX token PERSIST
"again"
127.0.0.1:6379> TTL token
(integer) -1
127.0.0.1:6379> GETDEL token
"again"
127.0.0.1:6379> GET token
(nil)
127.0.0.1:6379> SET key1 ohmytext
OK
127.0.0.1:6379> SET key2 mynewtext
OK
127.0.0.1:6379> LCS key1 key2
"mytext"
127.0.0.1:6379> LCS key1 key2 LEN
(integer) 6

GETEX 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:

cli/keys-strings-set-get-6.redis
127.0.0.1:6379> SET v1 hello
OK
127.0.0.1:6379> SET v1 world IFEQ hello
OK
127.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) 0
127.0.0.1:6379> DELEX v1 IFEQ world
(integer) 1
127.0.0.1:6379> EXISTS v1
(integer) 0

IFEQ 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.

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:

cli/keys-strings-set-get-7.redis
127.0.0.1:6379> SETEX session:user1 3600 "token-xyz"
OK
127.0.0.1:6379> TTL session:user1
(integer) 3600
127.0.0.1:6379> SETNX lock:job1 "worker-A"
(integer) 1
127.0.0.1:6379> SETNX lock:job1 "worker-B"
(integer) 0
127.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:job1

Set or get many keys in a single round-trip. This is significantly faster than individual commands when you need multiple keys at once.

cli/keys-strings-set-get-8.redis
127.0.0.1:6379> MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"
OK
127.0.0.1:6379> MGET user:1:name user:1:lang user:2:name user:2:lang
1) "Ada"
2) "Python"
3) "Bob"
4) "Go"
127.0.0.1:6379> MGET user:1:name user:99:name
1) "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:name

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.

src/keys-strings-set-get-mset.ts
// @run
import { 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 adds to the end of an existing string value (or creates the key if absent). STRLEN returns the byte length of the value.

cli/keys-strings-set-get-9.redis
127.0.0.1:6379> SET log "2024-01-01 boot"
OK
127.0.0.1:6379> APPEND log " | 2024-01-01 ready"
(integer) 34
127.0.0.1:6379> GET log
"2024-01-01 boot | 2024-01-01 ready"
127.0.0.1:6379> STRLEN log
(integer) 34
127.0.0.1:6379> SET greet hi
OK
127.0.0.1:6379> OBJECT ENCODING greet
"embstr"
127.0.0.1:6379> APPEND greet " there"
(integer) 8
127.0.0.1:6379> OBJECT ENCODING greet
"raw"
127.0.0.1:6379> SET num 42
OK
127.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 log

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:

src/keys-strings-set-get-client.ts
// @run
import { 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 there
console.log(await redis.set('b1:lock', 'worker-A', 'EX', 30, 'NX')); // "OK"
console.log(await redis.set('b1:lock', 'worker-B', 'EX', 30, 'NX')); // null
console.log(await redis.get('b1:lock')); // "worker-A"
// strings are binary-safe: store raw bytes, read them back as a Buffer
const 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.

An assistant writes “refresh the session data” for a login flow that stores sessions for 30 minutes:

🐛 Spot the bug

// login: session lives 30 minutes
await 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.

src/keys-strings-set-get-keepttl.ts
// @run
import { 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 TTL
await 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 expiry
await 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.

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.

ChoiceBenefitCost
SET key val NX (conditional set)Atomic check-then-set in one command; enough for a basic lock/dedupNo ownership token built in — you must design that yourself for safe unlocking
MSET/MGET (batch operations)Atomic across all the keys, and one round tripThe 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 / IFEQRemoves read-then-write races without LuaNewer flags need a recent server (GETEX/GETDEL 6.2, IFEQ/DELEX 8.4) — not on older Redis or Valkey
Plain unconditional SET key valSimple, overwrites immediately with no pre-checkOverwrites another client’s data if key names collide, and silently clears any TTL
  • “MSET is 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; STRLEN counts bytes (สวัสดี is 18), and the limit is 512 MB.
  • “SET only changes the value.” It also removes the TTL unless KEEPTTL is given.
  • “SET ... NX returns 1 or 0.” That is SETNX. SET ... NX returns OK or (nil) (null in ioredis).
  • Using a plain SET when you meant “set only if it doesn’t exist” — forgetting NX silently overwrites existing data. Use SET key val NX when the logic needs an atomic check-then-set.
  • Forgetting that a plain SET resets a key’s existing TTL — if you want to keep the existing expiry while updating the value, add KEEPTTL; otherwise the key becomes persistent again.
  • Mixing the legacy SETNX/SETEX commands with SET ... NX/EX in the same codebase — this produces inconsistent style. Standardize on SET with options, available since Redis 2.6.12.

Real-world example

Stripe’s public API documents an idempotency-key algorithm: a client sends an Idempotency-Key header, 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 what SET key val NX EX <ttl> gives you in Redis: the request that got OK is the one that actually runs.

What does SET key val NX return when the key already exists?
Which command sets multiple keys in one round-trip?
What does SET key val GET do?
STRLEN returns the length of a string in what unit?
SET session:42 data runs on a key that had EX 1800. What is TTL session:42 afterwards?
You need to atomically change a key only if it still holds the value you read. On Redis 8.4+ what is the simplest way?