Skip to content

Modeling Objects with Hashes

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. Memory numbers come from MEMORY USAGE and INFO memory used_memory deltas on this machine; the byte counts are specific to these field names and values (jemalloc, 64-bit).

One hash per object — the canonical pattern

Section titled “One hash per object — the canonical pattern”

The idiomatic way to model a domain object in Redis is to store all its fields under a single hash key. A user record, for example, lives entirely under user:1:

cli/lists-hashes-hash-objects-1.redis
127.0.0.1:6379> HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
(integer) 4
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Ada"
3) "email"
5) "age"
6) "28"
7) "points"
8) "0"

Compare that to the many-keys alternative:

ApproachKeys required
Hashuser:1 — one key, one HSET
Separate stringsuser:1:name, user:1:email, user:1:age, user:1:points — four keys

The hash approach uses less memory and lets you fetch or update a specific field — with HGET / HSET — without loading or touching any other field in the object. How much less memory? Measure it instead of trusting a rule of thumb.

HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
HGETALL user:1

Same four fields (name, email, age, points), stored two ways. For one object, MEMORY USAGE says:

LayoutBytesEncoding
1 hash user:187listpack
4 string keys157 (40 + 56 + 29 + 32)embstr each

For 100,000 users (used_memory before and after loading each layout into an empty server):

LayoutTotalPer user
100,000 hashes12.7 MB127 B
400,000 string keys28.2 MB282 B

So on this data the hashes take 45% of the memory — about 155 bytes less per object, a 2.2x difference. The earlier rule of thumb in this lesson (“roughly 50-80 bytes per object”) did not survive measurement: the saving depends on how many fields and how long the key names are, but every separate string key pays for its own key object, its own dictionary slot and its own allocator rounding. Redis’s own guidance says the same thing, in words — “use hashes when possible” because small hashes are encoded compactly (Memory optimization). The saving holds only while the hash stays a listpack (up to 512 fields, values up to 64 bytes — see the Hashes lesson); a hash that has turned into a hash table is not compact.

When a hash field holds a number, Redis can increment it atomically — no read-modify-write race:

  • HINCRBY key field increment — increment an integer field by an integer amount
  • HINCRBYFLOAT key field increment — increment a float field by a float amount
cli/lists-hashes-hash-objects-2.redis
127.0.0.1:6379> HINCRBY user:1 points 10
(integer) 10
127.0.0.1:6379> HINCRBY user:1 age 1
(integer) 29
127.0.0.1:6379> HINCRBYFLOAT user:1 score 1.5
"1.5"
127.0.0.1:6379> HINCRBYFLOAT user:1 score 0.5
"2"
127.0.0.1:6379> HGET user:1 points
"10"
127.0.0.1:6379> HINCRBY user:1 visits 5
(integer) 5

If the field does not exist yet, both commands treat its starting value as 0 before applying the increment, so HINCRBY user:1 visits 5 on a missing visits field creates it with the value 5. Note HINCRBYFLOAT ... 0.5 printed "2", not "2.0": the reply trims trailing zeros.

HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
HINCRBY user:1 points 10
HINCRBY user:1 age 1
HINCRBYFLOAT user:1 score 1.5
HINCRBYFLOAT user:1 score 0.5
HGET user:1 points

Twenty connections each add 1 to a points field 50 times, once with the read-then-write pattern and once with HINCRBY, then twenty connections race HSETNX for a created_at field:

src/lists-hashes-hash-objects-atomic.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const clients = Array.from({ length: 20 }, () => new Redis(url));
const admin = clients[0];
await admin.del('b1:user:1', 'b1:user:2');
await admin.hset('b1:user:1', { name: 'Ada', points: 0 });
await admin.hset('b1:user:2', { name: 'Bob', points: 0 });
// the AI-written way: read, add, write back (20 connections x 50 rounds)
await Promise.all(clients.map(async (c) => {
for (let i = 0; i < 50; i++) {
const p = Number(await c.hget('b1:user:1', 'points'));
await c.hset('b1:user:1', 'points', p + 1);
}
}));
console.log('HGET+HSET points:', await admin.hget('b1:user:1', 'points'), '(expected 1000)');
// the fix: HINCRBY is one atomic command
await Promise.all(clients.map(async (c) => { for (let i = 0; i < 50; i++) await c.hincrby('b1:user:2', 'points', 1); }));
console.log('HINCRBY points:', await admin.hget('b1:user:2', 'points'), '(expected 1000)');
// HSETNX: 20 writers race to stamp created_at, exactly one wins
const wins = await Promise.all(clients.map((c, i) => c.hsetnx('b1:user:2', 'created_at', String(i))));
console.log('HSETNX winners:', wins.reduce((a, b) => a + b, 0));
await admin.del('b1:user:1', 'b1:user:2');
clients.forEach((c) => c.disconnect());

Real output: HGET+HSET points: 91 (expected 1000), HINCRBY points: 1000 (expected 1000), HSETNX winners: 1. The read-modify-write version lost 909 of 1,000 increments (the exact number varies per run); HINCRBY lost none.

HSETNX key field value sets a field only if it does not already exist. It returns 1 if the field was created, 0 if it was already present and left unchanged.

cli/lists-hashes-hash-objects-3.redis
127.0.0.1:6379> HSETNX user:1 email "[email protected]"
(integer) 0
127.0.0.1:6379> HGET user:1 email
127.0.0.1:6379> HSETNX user:1 referrer "bob"
(integer) 1
127.0.0.1:6379> HGET user:1 referrer
"bob"

A practical use case: stamp a created_at field exactly once when the object is first written. Because HSETNX is atomic, two concurrent writers cannot both win — in the race above 20 writers produced exactly one 1.

HSET user:1 name "Ada" email "[email protected]"
HSETNX user:1 email "[email protected]"
HGET user:1 email
HSETNX user:1 referrer "bob"
HGET user:1 referrer

Hash vs many string keys — when to use which

Section titled “Hash vs many string keys — when to use which”
Hash (one key)Many string keys
MemoryCompact: 87 vs 157 bytes for four short fields; 2.2x at 100k objectsHigher overhead per key
Fetch one fieldHGET user:1 nameGET user:1:name
Fetch all fieldsHGETALL user:1MGET user:1:name user:1:email ...
Atomic incrementHINCRBYINCR user:1:points
TTL per fieldYes since Redis 7.4: HEXPIRE / HSETEX (see below)Yes (each key has its own TTL)
TTL for the whole objectEXPIRE user:1one EXPIRE per key (and they can drift apart)
Delete the objectone DEL/UNLINKdelete every key

Rule of thumb: prefer one hash per object. Splitting into separate keys only to get per-field expiry is no longer a reason.

Redis 7.4 added field expiration and Redis 8 folded it into HSETEX/HGETEX. An expiring one-time password field next to permanent profile fields, all under user:1:

cli/lists-hashes-hash-objects-4.redis
127.0.0.1:6379> HSETEX user:1 EX 60 FIELDS 1 otp 123456
(integer) 1
127.0.0.1:6379> HTTL user:1 FIELDS 2 otp email
1) (integer) 60
2) (integer) -1
127.0.0.1:6379> HMGET user:1 name otp
1) "Ada"
2) "123456"
127.0.0.1:6379> EXPIRE user:1 100
(integer) 1
127.0.0.1:6379> TTL user:1
(integer) 100
127.0.0.1:6379> OBJECT ENCODING user:1
"listpackex"

otp has a 60-second TTL, email has none (-1). EXPIRE user:1 100 still works on the whole hash, independently. And the encoding changed to listpackex: a listpack that carries per-field expiry metadata, so a hash with field TTLs is a little bigger than a plain one (MEMORY USAGE user:1 was 184 bytes here with eight fields including otp). It is a good trade for the case it enables, but not free — put a TTL on fields that need one, not on all of them.

hset(key, object) stores an object; hgetall returns strings. Wrap the conversion in one place so numbers and booleans come back typed, and remember that a missing key is an empty object:

// see also src/lists-hashes-hashes-cache.ts in the Hashes lesson
type User = { name: string; email: string; age: number; points: number };
const toUser = (h: Record<string, string>): User | null =>
Object.keys(h).length === 0 ? null : { name: h.name, email: h.email, age: Number(h.age), points: Number(h.points) };

An assistant writes a loyalty-points update for the user hash and explains that it is “safe because Redis is single-threaded”:

🐛 Spot the bug

async function addPoints(userId: number, n: number) {
const key = 'user:' + userId;
const current = Number(await redis.hget(key, 'points')); // read
await redis.hset(key, 'points', current + n); // write back
}
Show the answer

Each Redis command is atomic, but this function is two commands with the application in between. Under concurrency the increments overwrite each other. We ran it: 20 connections each awarding 1 point 50 times ended at points: 91, not 1000; the same workload with HINCRBY ended at exactly 1000 (script above). The fix is one command:

await redis.hincrby('user:' + userId, 'points', n); // atomic; creates the field at 0 if missing

A hash per object is a record card: the key is the filing-cabinet slot, the fields are lines on the card. Update one line without rewriting the card (HSET), add to a number on the card in place (HINCRBY), stamp a line only if blank (HSETNX), and put an expiry on a single line (HEXPIRE) or on the entire card (EXPIRE). Four loose sticky notes (four string keys) cost more paper than one card — measured at 2.2x — and are four things to delete, expire and keep consistent.

ChoiceBenefitCost
One hash per objectLess memory (45% of the string layout in our test); update one field with HGET/HSET without touching the others; delete the object in one commandValues are untyped strings; fields with a TTL turn the encoding into listpackex (a bit larger); a hash over 512 fields or 64-byte values loses the compact encoding
HINCRBY/HINCRBYFLOAT for numeric fieldsAtomic increment, no read-modify-write race (0 lost vs 909 lost of 1000)Only works on numeric fields; every other field still needs a plain HSET
HSETNX for conditional fieldsGuarantees only the first writer sets the value — good for created_atCannot update an existing value; use plain HSET when you want to overwrite
Many string keys insteadEach key can be evicted, expired or moved independently; works on Redis < 7.4 for per-field TTL2.2x the memory here, and multiple keys to keep consistent
  • “Separate keys are required if I want per-field TTL.” Not since Redis 7.4 (HEXPIRE, and HSETEX in 8.0).
  • “A hash saves 50-80 bytes.” We measured 155 bytes per four-field object; the real number depends on your data — measure with MEMORY USAGE.
  • “Single-threaded means HGET then HSET is safe.” It is two commands; the app in between is the race (909 of 1,000 lost).
  • “Field TTL is free.” The hash becomes listpackex, which is slightly larger.
  • Splitting one object’s fields into many string keys like user:1:name, user:1:email — this wastes memory (2.2x measured) and multiplies deletes and expirations. Combine them into a single hash; use field-level TTL if some fields must expire.
  • Using plain HSET instead of HINCRBY to update a numeric field — this requires an HGET then HSET round trip, opening a race condition when multiple writers run concurrently.
  • Forgetting that HSETNX does not overwrite an existing value — it is easy to mistake it for “set or update,” but it only acts when the field does not exist yet.
  • Trusting a memory rule of thumb — measure MEMORY USAGE on your real objects.

Real-world example

Redis’s own guidance (Memory optimization, Redis Docs) recommends exactly this: model a plain key-value store using hashes because a few large hashes cost far less memory than many individual keys, since small hashes use the compact listpack encoding shown above.

What does HINCRBY user:1 points 5 do if points field does not exist yet?
What does HSETNX user:1 email '[email protected]' return if the field already exists?
Which approach uses less memory for storing a user object in Redis?
Which command atomically increments a float field in a hash?
You need one field of a per-user hash to expire after 60 seconds while the rest stays. What is the modern approach?
20 clients each run HGET points, then HSET points (n+1), 50 times. What is the expected final value, and what did we observe?