Hashes
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac.HEXPIRE/HTTLneed Redis 7.4+,HGETEX/HSETEX/HGETDELneed 8.0+ (not on Valkey or older servers). Timings are server-sideSLOWLOGmicroseconds on this machine.
Hashes — a flat dictionary under one key
Section titled “Hashes — a flat dictionary under one key”Redis Hashes store multiple field-value pairs under a single key. Think of it as a flat dictionary or a row in a database — one key holds many named fields. Fields and values are both strings (binary-safe, like everything in Redis).
HSET and HGET
Section titled “HSET and HGET”HSET key field value [field value ...] sets one or more fields (variadic since Redis 4.0; HMSET is the deprecated older name). HGET key field retrieves one field by name. If the field does not exist, Redis returns (nil). HSET returns the number of fields that were created — updating an existing field does not count:
127.0.0.1:6379> HSET product:1 name "Redis Mug" price "12.99" stock "200"(integer) 3127.0.0.1:6379> HGET product:1 name"Redis Mug"127.0.0.1:6379> HGET product:1 price"12.99"127.0.0.1:6379> HGET product:1 missing-field(nil)127.0.0.1:6379> HSET product:1 name "Redis Mug XL" color "red"(integer) 1The last call updated name and added color, and returned 1: only the new field counts. Don’t use the return value of HSET as “did it succeed”.
HSET product:1 name "Redis Mug" price "12.99" stock "200"
HGET product:1 name
HGET product:1 price
HGET product:1 missing-fieldHMGET and HGETALL
Section titled “HMGET and HGETALL”HMGET key field [field ...] fetches multiple fields in one round-trip and returns (nil) for any field that does not exist. HGETALL key returns every field-value pair in the hash as an alternating list of field names and values.
127.0.0.1:6379> HMGET product:1 name stock nope1) "Redis Mug XL"2) "200"3) (nil)127.0.0.1:6379> HGETALL product:11) "name"2) "Redis Mug XL"3) "price"4) "12.99"5) "stock"6) "200"7) "color"8) "red"For a small hash the fields come back in insertion order, but that is an implementation detail of the compact encoding (below) — the contract says unordered.
HMGET product:1 name stock
HGETALL product:1HDEL and HEXISTS
Section titled “HDEL and HEXISTS”HDEL key field [field ...] removes one or more fields and returns the number of fields actually deleted. HEXISTS key field returns 1 if the field exists, 0 if it does not — useful for conditional logic without fetching the value itself.
127.0.0.1:6379> HEXISTS product:1 price(integer) 1127.0.0.1:6379> HEXISTS product:1 nosuchfield(integer) 0127.0.0.1:6379> HDEL product:1 price(integer) 1127.0.0.1:6379> HDEL product:1 price(integer) 0127.0.0.1:6379> HEXISTS product:1 price(integer) 0The second HDEL returned 0, not an error: the field was already gone.
HSET product:1 name "Redis Mug" price "12.99" stock "200"
HEXISTS product:1 price
HEXISTS product:1 color
HDEL product:1 price
HEXISTS product:1 price
HKEYS product:1
HVALS product:1
HLEN product:1HKEYS, HVALS, HLEN and friends
Section titled “HKEYS, HVALS, HLEN and friends”HKEYS key returns all field names. HVALS key returns all field values. HLEN key returns the total number of fields in the hash. After deleting price above, the hash has three fields: name, stock and color. HSTRLEN gives the byte length of one field’s value, HSETNX sets a field only if it does not exist, and HINCRBY/HINCRBYFLOAT add to a numeric field atomically (creating it at 0 if missing):
127.0.0.1:6379> HKEYS product:11) "name"2) "stock"3) "color"127.0.0.1:6379> HVALS product:11) "Redis Mug XL"2) "200"3) "red"127.0.0.1:6379> HLEN product:1(integer) 3127.0.0.1:6379> HSTRLEN product:1 name(integer) 12127.0.0.1:6379> HSETNX product:1 name "ignored"(integer) 0127.0.0.1:6379> HSETNX product:1 sku "MUG-1"(integer) 1127.0.0.1:6379> HINCRBY product:1 stock -3(integer) 197127.0.0.1:6379> HINCRBY product:1 rating 5(integer) 5127.0.0.1:6379> HINCRBYFLOAT product:1 weight 0.25"0.25"127.0.0.1:6379> HINCRBY product:1 name 1(error) ERR hash value is not an integerHRANDFIELD key [count [WITHVALUES]] (6.2+) returns random fields — handy for sampling — and a negative count allows repeats. Its output is random by definition, so here is one real run rather than a fixed transcript:
127.0.0.1:6379> HRANDFIELD product:1 2 WITHVALUES1) "stock"2) "197"3) "sku"4) "MUG-1"How Redis stores a hash: listpack, then hashtable
Section titled “How Redis stores a hash: listpack, then hashtable”A small hash is one compact block (a listpack); a big one is a real hash table. Two settings decide: hash-max-listpack-entries (default 512 fields) and hash-max-listpack-value (default 64 bytes per field or value). Measured on 8.10.2:
1 field: listpack, MEMORY USAGE 37 bytes513 fields (value 'v'): hashtable, MEMORY USAGE 22,014 bytesa 64-byte value -> listpack a 65-byte value -> hashtableThe 513th field and the 65th byte each flip it. The flip is one-way in practice and you should not care about it in application code — but it tells you why small objects are cheap and why a hash with 100,000 fields (the next section) is a different animal.
HGETALL on a big hash
Section titled “HGETALL on a big hash”HGETALL is O(N) in the number of fields, and it runs on the command thread. I built a hash with 100,000 fields (field:<n> → value-<n>, 6 MB, hashtable) and read the server-side time:
| Command | Server time on the 100,000-field hash |
|---|---|
HGET one field / HMGET two fields | 2 µs |
HGETALL | 7,818 µs (7.8 ms) |
HVALS | 5,057 µs |
HSCAN ... COUNT 100 (one call) | ≤ 34 µs, 993 calls to walk everything |
Client-side, HGETALL delivered all 100,000 fields in 135 ms; the HSCAN walk took 450 ms in total but never held the server for more than a few dozen microseconds. Same rule as SCAN for keys: if a hash can grow without bound, iterate it with HSCAN (ioredis: hscanStream) — and if you only need a few fields, name them with HMGET.
Expiring individual fields
Section titled “Expiring individual fields”EXPIRE works on a whole key, so for a long time a hash was all-or-nothing: every field lived and died together. Redis 7.4 changed that with field-level TTL, and Redis 8 added three commands that fold the TTL into the read or write itself.
HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field ...] sets the TTL. Note the FIELDS keyword and the field count before the names — every command in this family takes them. It returns one number per field: 1 when the TTL was set, -2 when the field does not exist, 0 when an NX/XX/GT/LT condition was not met. HTTL reads the remaining seconds and HPERSIST removes the TTL again.
127.0.0.1:6379> HSET session:42 user "ana" csrf "t0ken"(integer) 2127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HTTL session:42 FIELDS 2 csrf user1) (integer) 6002) (integer) -1127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 2 csrf nope1) (integer) 12) (integer) -2127.0.0.1:6379> HEXPIRE session:42 100 NX FIELDS 1 csrf1) (integer) 0127.0.0.1:6379> HEXPIRE session:42 100 XX FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HPERSIST session:42 FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HTTL session:42 FIELDS 1 csrf1) (integer) -1A -1 means the field has no expiration. NX was refused (0) because csrf already had a TTL; XX applied. Once the TTL runs out the field disappears on its own, and when the last field goes the key goes with it — I set HPEXPIRE tiny 300 FIELDS 1 f on a one-field hash: EXISTS tiny was 1 at once and 0 about 600 ms later.
HGETEX, HSETEX, and HGETDEL
Section titled “HGETEX, HSETEX, and HGETDEL”These three Redis 8 commands each replace a small multi-command dance:
| Command | Shape | Replaces |
|---|---|---|
HGETEX key [EX seconds | PERSIST] FIELDS numfields field [field ...] | read and re-arm the TTL | HGET + HEXPIRE |
HSETEX key [FNX | FXX] [EX seconds | KEEPTTL] FIELDS numfields field value [field value ...] | write with a TTL | HSET + HEXPIRE |
HGETDEL key FIELDS numfields field [field ...] | read and remove | HGET + HDEL |
HGETEX is the sliding-session primitive: read the field and push its expiry out in the same atomic call. HSETEX takes FNX (only if none of the fields exist) or FXX (only if all of them do), plus KEEPTTL to leave an existing expiry alone. HGETDEL is the one-shot read used for consume-once values like a one-time token.
127.0.0.1:6379> HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"(integer) 1127.0.0.1:6379> HGETEX session:42 EX 900 FIELDS 1 csrf1) "fresh-token"127.0.0.1:6379> HGETDEL session:42 FIELDS 1 csrf1) "fresh-token"127.0.0.1:6379> HGET session:42 csrf(nil)HSET session:42 user "ana" csrf "t0ken"
HEXPIRE session:42 600 FIELDS 1 csrf
HTTL session:42 FIELDS 2 csrf user
HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"
HGETEX session:42 EX 900 FIELDS 1 csrf
HGETDEL session:42 FIELDS 1 csrf
HGET session:42 csrfFrom Node.js
Section titled “From Node.js”ioredis takes an object for hset and gives you a Record<string, string> from hgetall — every value comes back as a string, and a missing key comes back as {}. The field-TTL commands are wrapped (hexpire, httl); the Redis 8 ones can be sent with call() if your client version lacks a wrapper:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:product:1';await redis.del(key);
// hset takes an object; every value is stored (and returned) as a stringawait redis.hset(key, { name: 'Redis Mug', price: 12.99, stock: 200 });const p = await redis.hgetall(key); // Record<string, string>console.log(p, typeof p.price); // price is the string '12.99', not a numberconsole.log(Number(p.price) * 2); // parse before doing math
console.log(await redis.hmget(key, 'name', 'nope')); // [ 'Redis Mug', null ]console.log(await redis.hincrby(key, 'stock', -3)); // 197 (a number, atomic)
// field-level TTL: HEXPIRE key seconds FIELDS n field... (Redis 7.4+)await redis.hset('b1:sess', { user: 'ana', csrf: 't0ken' });console.log(await redis.hexpire('b1:sess', 600, 'FIELDS', 1, 'csrf')); // [ 1 ]console.log(await redis.httl('b1:sess', 'FIELDS', 2, 'csrf', 'user')); // [ 600, -1 ]
// HGETEX / HSETEX / HGETDEL (Redis 8.0+) are sent through call() if your client version has no wrapperconsole.log(await redis.call('HGETEX', 'b1:sess', 'EX', 900, 'FIELDS', 1, 'csrf')); // [ 't0ken' ]
await redis.del(key, 'b1:sess');await redis.quit();Real output: { name: 'Redis Mug', price: '12.99', stock: '200' } string, 25.98, [ 'Redis Mug', null ], 197, [ 1 ], [ 600, -1 ], [ 't0ken' ].
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a user-profile cache on a hash:
🐛 Spot the bug
async function getUser(id: number) {const user = await redis.hgetall('user:' + id);if (!user) { return loadFromDatabaseAndCache(id); // cache miss}return { name: user.name, isAdmin: !!user.isAdmin, age: user.age };}
await redis.hset('user:1', { name: 'Ana', isAdmin: false, age: 17 });Show the answer
Two separate bugs, both from “hashes are typed objects”. (1) HGETALL on a missing key returns an empty object {}, which is truthy, so !user is never true: the cache-miss branch never runs and callers get undefined fields for unknown users. (2) Every hash value is a string: isAdmin: false is stored and read back as "false", and !!"false" is true — Ana, aged 17, is reported as an admin; age is "17", so user.age > 18 style comparisons and + concatenation misbehave. Run:
isAdmin (buggy): ADMIN | stored value: "false"cache miss detected (buggy)? false | what HGETALL returned: {}fixed: { name: 'Ana', isAdmin: false, age: 17 } nullThe fix parses explicitly and detects a miss by an empty result (or EXISTS):
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.del('b1:user:1', 'b1:user:2');await redis.hset('b1:user:1', { name: 'Ana', isAdmin: false, age: 17 });
// the AI-written checksconst buggy = await redis.hgetall('b1:user:1');console.log('isAdmin (buggy):', buggy.isAdmin ? 'ADMIN' : 'user', '| stored value:', JSON.stringify(buggy.isAdmin)); // "false" is truthyconst missing = await redis.hgetall('b1:user:2');console.log('cache miss detected (buggy)?', !missing, '| what HGETALL returned:', JSON.stringify(missing)); // {} is truthy
// the fix: parse explicitly, detect a miss by emptiness (or EXISTS)const parse = (h: Record<string, string>) => Object.keys(h).length === 0 ? null : { name: h.name, isAdmin: h.isAdmin === 'true', age: Number(h.age) };console.log('fixed:', parse(buggy), parse(missing));
await redis.del('b1:user:1');await redis.quit();Mental Model
Section titled “Mental Model”A hash is a small spreadsheet row stored under one key, with the column headers repeated in every row. You can read or change one cell (HGET/HSET/HINCRBY) without touching the rest, and — since 7.4 — put an alarm on a single cell. Everything in it is text, the row itself has no schema, and there is no such thing as an empty row: no fields means no key. Treat HGETALL like SELECT *: fine on a narrow row, dangerous on a table.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Hash (fields as field-value pairs) | Read/write one field without parsing the whole blob (HGET/HSET); atomic HINCRBY; per-field TTL (7.4+) | No built-in nested structure or types — you flatten complex data yourself and parse strings back |
| String holding a JSON blob | Naturally handles complex/nested data and real types | Must GET the whole thing and parse it every time, even for one field; always overwrites the whole blob |
HGETALL | One call, whole object | O(N) on the command thread: 7.8 ms and 100,000 fields shipped for a 100k-field hash; use HMGET or HSCAN |
HDEL one field at a time | Removes exactly the field you don’t want, without touching the others | You need to know the field name up front — no built-in wildcard delete |
Common Misconceptions
Section titled “Common Misconceptions”- “A hash stores typed values.” Everything is a string;
falsebecomes"false"and17becomes"17". - “
HGETALLon a missing key isnull.” It is an empty array ({}in ioredis). Check emptiness. - “
HSETreturns whether it worked.” It returns the number of new fields; updates count 0. - “You can’t expire a field.” Since Redis 7.4 you can (
HEXPIRE); the older advice to split fields into separate keys for TTL no longer applies.
Common mistakes
Section titled “Common mistakes”- Storing an object as a JSON string when most fields are flat key-value pairs — this pays a parse/serialize round trip every time you read or write even a single field.
- Calling
HGETALLon a hash with many fields, repeatedly — this loads every field into client memory when you only needed one or two. UseHMGETorHGETinstead, orHSCANfor a big hash. - Forgetting that
HDELreturns the count of fields actually deleted, not a boolean — a return of0is easy to mistake for an error, but it just means the field did not exist in the first place. - Reading numbers back without converting —
hgetallgives strings;Number()them, and store booleans deliberately ('1'/'0'or'true'/'false').
Real-world example
Redis’s own documentation presents hashes as the type for representing objects such as a user or product, with
HINCRBYfor counters inside them (Redis hashes); the per-field expiration commands are documented on the 7.4HEXPIREpage (HEXPIRE).