Skip to content

Hashes

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. HEXPIRE/HTTL need Redis 7.4+, HGETEX/HSETEX/HGETDEL need 8.0+ (not on Valkey or older servers). Timings are server-side SLOWLOG microseconds 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 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:

cli/lists-hashes-hashes-1.redis
127.0.0.1:6379> HSET product:1 name "Redis Mug" price "12.99" stock "200"
(integer) 3
127.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) 1

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

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.

cli/lists-hashes-hashes-2.redis
127.0.0.1:6379> HMGET product:1 name stock nope
1) "Redis Mug XL"
2) "200"
3) (nil)
127.0.0.1:6379> HGETALL product:1
1) "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:1

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.

cli/lists-hashes-hashes-3.redis
127.0.0.1:6379> HEXISTS product:1 price
(integer) 1
127.0.0.1:6379> HEXISTS product:1 nosuchfield
(integer) 0
127.0.0.1:6379> HDEL product:1 price
(integer) 1
127.0.0.1:6379> HDEL product:1 price
(integer) 0
127.0.0.1:6379> HEXISTS product:1 price
(integer) 0

The 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:1

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

cli/lists-hashes-hashes-4.redis
127.0.0.1:6379> HKEYS product:1
1) "name"
2) "stock"
3) "color"
127.0.0.1:6379> HVALS product:1
1) "Redis Mug XL"
2) "200"
3) "red"
127.0.0.1:6379> HLEN product:1
(integer) 3
127.0.0.1:6379> HSTRLEN product:1 name
(integer) 12
127.0.0.1:6379> HSETNX product:1 name "ignored"
(integer) 0
127.0.0.1:6379> HSETNX product:1 sku "MUG-1"
(integer) 1
127.0.0.1:6379> HINCRBY product:1 stock -3
(integer) 197
127.0.0.1:6379> HINCRBY product:1 rating 5
(integer) 5
127.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 integer

HRANDFIELD 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 WITHVALUES
1) "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 bytes
513 fields (value 'v'): hashtable, MEMORY USAGE 22,014 bytes
a 64-byte value -> listpack a 65-byte value -> hashtable

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

CommandServer time on the 100,000-field hash
HGET one field / HMGET two fields2 µs
HGETALL7,818 µs (7.8 ms)
HVALS5,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.

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.

cli/lists-hashes-hashes-5.redis
127.0.0.1:6379> HSET session:42 user "ana" csrf "t0ken"
(integer) 2
127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HTTL session:42 FIELDS 2 csrf user
1) (integer) 600
2) (integer) -1
127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 2 csrf nope
1) (integer) 1
2) (integer) -2
127.0.0.1:6379> HEXPIRE session:42 100 NX FIELDS 1 csrf
1) (integer) 0
127.0.0.1:6379> HEXPIRE session:42 100 XX FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HPERSIST session:42 FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HTTL session:42 FIELDS 1 csrf
1) (integer) -1

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

These three Redis 8 commands each replace a small multi-command dance:

CommandShapeReplaces
HGETEX key [EX seconds | PERSIST] FIELDS numfields field [field ...]read and re-arm the TTLHGET + HEXPIRE
HSETEX key [FNX | FXX] [EX seconds | KEEPTTL] FIELDS numfields field value [field value ...]write with a TTLHSET + HEXPIRE
HGETDEL key FIELDS numfields field [field ...]read and removeHGET + 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.

cli/lists-hashes-hashes-6.redis
127.0.0.1:6379> HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"
(integer) 1
127.0.0.1:6379> HGETEX session:42 EX 900 FIELDS 1 csrf
1) "fresh-token"
127.0.0.1:6379> HGETDEL session:42 FIELDS 1 csrf
1) "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 csrf

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:

src/lists-hashes-hashes-client.ts
// @run
import { 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 string
await 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 number
console.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 wrapper
console.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' ].

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 } null

The fix parses explicitly and detects a miss by an empty result (or EXISTS):

src/lists-hashes-hashes-cache.ts
// @run
import { 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 checks
const buggy = await redis.hgetall('b1:user:1');
console.log('isAdmin (buggy):', buggy.isAdmin ? 'ADMIN' : 'user', '| stored value:', JSON.stringify(buggy.isAdmin)); // "false" is truthy
const 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();

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.

ChoiceBenefitCost
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 blobNaturally handles complex/nested data and real typesMust GET the whole thing and parse it every time, even for one field; always overwrites the whole blob
HGETALLOne call, whole objectO(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 timeRemoves exactly the field you don’t want, without touching the othersYou need to know the field name up front — no built-in wildcard delete
  • “A hash stores typed values.” Everything is a string; false becomes "false" and 17 becomes "17".
  • “HGETALL on a missing key is null.” It is an empty array ({} in ioredis). Check emptiness.
  • “HSET returns 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.
  • 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 HGETALL on a hash with many fields, repeatedly — this loads every field into client memory when you only needed one or two. Use HMGET or HGET instead, or HSCAN for a big hash.
  • Forgetting that HDEL returns the count of fields actually deleted, not a boolean — a return of 0 is easy to mistake for an error, but it just means the field did not exist in the first place.
  • Reading numbers back without converting — hgetall gives 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 HINCRBY for counters inside them (Redis hashes); the per-field expiration commands are documented on the 7.4 HEXPIRE page (HEXPIRE).

Which command returns ALL field-value pairs of a hash?
What does HGET key missing-field return?
How many fields does HSET user:1 name 'Ana' age '30' create?
Which command counts the number of fields in a hash?
You want to read a session field and push its expiry out in one atomic call. Which command?
In ioredis, await redis.hgetall('user:404') for a key that does not exist returns...
A hash stays a compact listpack until it exceeds which default limits?