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 fromMEMORY USAGEandINFO memoryused_memorydeltas 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:
127.0.0.1:6379> HSET user:1 name "Ada" email "[email protected]" age "28" points "0"(integer) 4127.0.0.1:6379> HGETALL user:11) "name"2) "Ada"3) "email"4) "[email protected]"5) "age"6) "28"7) "points"8) "0"Compare that to the many-keys alternative:
| Approach | Keys required |
|---|---|
| Hash | user:1 — one key, one HSET |
| Separate strings | user: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:1How much memory does a hash really save?
Section titled “How much memory does a hash really save?”Same four fields (name, email, age, points), stored two ways. For one object, MEMORY USAGE says:
| Layout | Bytes | Encoding |
|---|---|---|
1 hash user:1 | 87 | listpack |
| 4 string keys | 157 (40 + 56 + 29 + 32) | embstr each |
For 100,000 users (used_memory before and after loading each layout into an empty server):
| Layout | Total | Per user |
|---|---|---|
| 100,000 hashes | 12.7 MB | 127 B |
| 400,000 string keys | 28.2 MB | 282 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.
HINCRBY and HINCRBYFLOAT
Section titled “HINCRBY and HINCRBYFLOAT”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 amountHINCRBYFLOAT key field increment— increment a float field by a float amount
127.0.0.1:6379> HINCRBY user:1 points 10(integer) 10127.0.0.1:6379> HINCRBY user:1 age 1(integer) 29127.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) 5If 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 pointsProof: HINCRBY under concurrency
Section titled “Proof: HINCRBY under concurrency”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:
// @runimport { 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 commandawait 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 winsconst 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 — conditional field set
Section titled “HSETNX — conditional field set”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.
127.0.0.1:6379> HSETNX user:1 email "[email protected]"(integer) 0127.0.0.1:6379> HGET user:1 email127.0.0.1:6379> HSETNX user:1 referrer "bob"(integer) 1127.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 referrerHash 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 | |
|---|---|---|
| Memory | Compact: 87 vs 157 bytes for four short fields; 2.2x at 100k objects | Higher overhead per key |
| Fetch one field | HGET user:1 name | GET user:1:name |
| Fetch all fields | HGETALL user:1 | MGET user:1:name user:1:email ... |
| Atomic increment | HINCRBY | INCR user:1:points |
| TTL per field | Yes since Redis 7.4: HEXPIRE / HSETEX (see below) | Yes (each key has its own TTL) |
| TTL for the whole object | EXPIRE user:1 | one EXPIRE per key (and they can drift apart) |
| Delete the object | one DEL/UNLINK | delete 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.
Per-field TTL on an object hash
Section titled “Per-field TTL on an object hash”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:
127.0.0.1:6379> HSETEX user:1 EX 60 FIELDS 1 otp 123456(integer) 1127.0.0.1:6379> HTTL user:1 FIELDS 2 otp email1) (integer) 602) (integer) -1127.0.0.1:6379> HMGET user:1 name otp1) "Ada"2) "123456"127.0.0.1:6379> EXPIRE user:1 100(integer) 1127.0.0.1:6379> TTL user:1(integer) 100127.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.
From Node.js
Section titled “From Node.js”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 lessontype 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) };Reading AI-generated code
Section titled “Reading AI-generated code”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')); // readawait 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 missingMental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| One hash per object | Less memory (45% of the string layout in our test); update one field with HGET/HSET without touching the others; delete the object in one command | Values 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 fields | Atomic 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 fields | Guarantees only the first writer sets the value — good for created_at | Cannot update an existing value; use plain HSET when you want to overwrite |
| Many string keys instead | Each key can be evicted, expired or moved independently; works on Redis < 7.4 for per-field TTL | 2.2x the memory here, and multiple keys to keep consistent |
Common Misconceptions
Section titled “Common Misconceptions”- “Separate keys are required if I want per-field TTL.” Not since Redis 7.4 (
HEXPIRE, andHSETEXin 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
HGETthenHSETis 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.
Common mistakes
Section titled “Common mistakes”- 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
HSETinstead ofHINCRBYto update a numeric field — this requires anHGETthenHSETround trip, opening a race condition when multiple writers run concurrently. - Forgetting that
HSETNXdoes 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 USAGEon 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.