Redis hashes map field names to string values within a single key—essentially a small dictionary living under one Redis key. They are the natural choice for modeling objects (users, products, sessions) when you need field-level reads and writes without serializing the entire document on every change.
Hashes can use memory-efficient encodings (listpack/hash table) depending on field count and value sizes. Redis 7.4+ adds field-level expiration with HEXPIRE, bringing TTL granularity that previously required workarounds. This chapter compares hashes to plain strings, covers counter patterns, and positions hashes relative to RedisJSON.
You store user profiles with fields like name, email, avatar URL, and last login. Fetching the whole profile on every request is wasteful when you only need one field; storing each field as a separate Redis key explodes key count and loses atomic object semantics.
Use a single hash per object: HSET user:1001 name "Alice" email "alice@example.com" avatar "/img/a.png".
redis-cli:
127.0.0.1:6379> HSET user:1001 name "Alice" email "alice@example.com" avatar "/img/a.png"
(integer) 3
127.0.0.1:6379> HGET user:1001 name
"Alice"
127.0.0.1:6379> HGETALL user:1001
1) "name"
2) "Alice"
3) "email"
4) "alice@example.com"
5) "avatar"
6) "/img/a.png"
127.0.0.1:6379> HMGET user:1001 name email
1) "Alice"
2) "alice@example.com"
127.0.0.1:6379> HEXISTS user:1001 phone
(integer) 0
127.0.0.1:6379> HLEN user:1001
(integer) 3
Python:
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def save_user(user_id: int, profile: dict) -> None:
r.hset(f"user:{user_id}", mapping=profile)
def get_user_field(user_id: int, field: str) -> str | None:
return r.hget(f"user:{user_id}", field)
def get_user_profile(user_id: int) -> dict:
return r.hgetall(f"user:{user_id}")
def update_email(user_id: int, email: str) -> None:
r.hset(f"user:{user_id}", "email", email)Node.js:
const Redis = require("ioredis");
const redis = new Redis();
async function saveUser(userId, profile) {
await redis.hset(`user:${userId}`, profile);
}
async function getUserField(userId, field) {
return redis.hget(`user:${userId}`, field);
}
async function getUserProfile(userId) {
return redis.hgetall(`user:${userId}`);
}Go:
func saveUser(ctx context.Context, rdb *redis.Client, userID int64, profile map[string]interface{}) error {
return rdb.HSet(ctx, fmt.Sprintf("user:%d", userID), profile).Err()
}
func getUserField(ctx context.Context, rdb *redis.Client, userID int64, field string) (string, error) {
return rdb.HGet(ctx, fmt.Sprintf("user:%d", userID), field).Result()
}
func getUserProfile(ctx context.Context, rdb *redis.Client, userID int64) (map[string]string, error) {
return rdb.HGetAll(ctx, fmt.Sprintf("user:%d", userID)).Result()
}HGET is O(1) per field. HGETALL is O(N) in field count—fine for profiles with < 50 fields, avoid on wide hashes with hundreds of columns.
HSET 4.0+ accepts multiple field-value pairs in one call, reducing round trips. HSETNX sets a field only if it does not exist—useful for created_at immutability.
Map hash field names to stable schema conventions (snake_case, documented in your team wiki). Hashes do not enforce schema—application validation is required.
For nested objects (address with street, city, zip), either flatten (address_city) or store JSON in one field while keeping hot scalar fields at top level.
You're deciding between user:1001:name, user:1001:email as separate string keys versus one user:1001 hash. Memory, command count, and Cluster behavior differ.
Compare both approaches:
Multiple string keys:
127.0.0.1:6379> SET user:1001:name "Alice"
OK
127.0.0.1:6379> SET user:1001:email "alice@example.com"
OK
127.0.0.1:6379> MGET user:1001:name user:1001:email
1) "Alice"
2) "alice@example.com"
Single hash:
127.0.0.1:6379> HSET user:1001 name "Alice" email "alice@example.com"
(integer) 2
127.0.0.1:6379> HMGET user:1001 name email
1) "Alice"
2) "alice@example.com"
Python comparison:
# Strings: independent TTL per field possible
r.set("user:1001:name", "Alice", ex=3600)
r.set("user:1001:email", "alice@example.com") # no TTL
# Hash: one TTL on entire object
r.hset("user:1001", mapping={"name": "Alice", "email": "alice@example.com"})
r.expire("user:1001", 3600)| Factor | Hash (one key) | Multiple strings |
|---|---|---|
| Key overhead | One key metadata block | N key metadata blocks |
| Small fields | Often listpack-packed—very efficient | Each key has full overhead |
| Field-level TTL | HEXPIRE (7.4+) or manual | Native per key |
| Partial update | HSET one field | SET one key |
| Atomic read-all | HGETALL | MGET (must list keys) |
| Cluster slot | One slot | All keys must be same slot for atomic MGET |
| Max fields | Practical limit ~ hundreds to low thousands | Unlimited keys |
Rule of thumb: Group related fields that are read and expired together into a hash. Split into separate keys when fields have vastly different TTLs (before HEXPIRE), extreme size disparity (multi-MB blob vs 10-byte flag), or independent hot-spot access patterns.
Run MEMORY USAGE user:1001 vs sum of string keys in staging with representative data—the hash often wins for small objects with 5–20 fields.
A user object tracks login_count, points, and unread_messages—counters that increment frequently. You want atomic increments without read-modify-write on the full object.
HINCRBY key field increment atomically increments an integer field. HINCRBYFLOAT handles fractional values.
redis-cli:
127.0.0.1:6379> HSET user:1001 login_count 0 points 100
(integer) 2
127.0.0.1:6379> HINCRBY user:1001 login_count 1
(integer) 1
127.0.0.1:6379> HINCRBY user:1001 points 25
(integer) 125
127.0.0.1:6379> HINCRBY user:1001 unread_messages 1
(integer) 1
127.0.0.1:6379> HGETALL user:1001
1) "login_count"
2) "1"
3) "points"
4) "125"
5) "unread_messages"
6) "1"
Initialize-on-first-increment is automatic—HINCRBY on a missing field treats it as 0.
Python:
def record_login(user_id: int) -> int:
return r.hincrby(f"user:{user_id}", "login_count", 1)
def add_points(user_id: int, amount: int) -> int:
return r.hincrby(f"user:{user_id}", "points", amount)
def increment_unread(user_id: int, delta: int = 1) -> int:
return r.hincrby(f"user:{user_id}", "unread_messages", delta)Node.js:
async function recordLogin(userId) {
return redis.hincrby(`user:${userId}`, "login_count", 1);
}
async function addPoints(userId, amount) {
return redis.hincrby(`user:${userId}`, "points", amount);
}Go:
func recordLogin(ctx context.Context, rdb *redis.Client, userID int64) (int64, error) {
return rdb.HIncrBy(ctx, fmt.Sprintf("user:%d", userID), "login_count", 1).Result()
}HINCRBY is O(1). Field values must remain representable as Redis integers (64-bit signed). Overflow behaves like string INCR—errors on non-integer field values.
Contrast with string counters: INCR user:1001:login_count uses a separate key per counter—more overhead but independent TTL and no hash-wide locking contention (Redis is single-threaded per command anyway, but hash fields share one key namespace for memory accounting).
Multi-field atomic updates: Use MULTI/EXEC or Lua when incrementing points and decrementing inventory must succeed together:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HINCRBY user:1001 points -50
QUEUED
127.0.0.1:6379> HINCRBY shop:inventory:SKU-42 qty -1
QUEUED
127.0.0.1:6379> EXEC
For high-cardinality per-entity counters (every product SKU in one hash), watch hash width—prefer separate keys or Sorted Sets when field count explodes.
A session hash stores user_id, device, and a short-lived oauth_token. You previously had to either expire the entire session when the token aged out, or store the token in a separate string key with its own TTL.
HEXPIRE key seconds FIELDS numfields field [field ...] sets TTL on individual hash fields. Related commands: HPEXPIRE, HEXPIREAT, HPEXPIREAT, HTTL, HPTTL, HPERSIST.
redis-cli (Redis 7.4+):
127.0.0.1:6379> HSET session:abc123 user_id "1001" device "mobile" oauth_token "tok_xyz"
(integer) 3
127.0.0.1:6379> HEXPIRE session:abc123 300 FIELDS 1 oauth_token
1) (integer) 1
127.0.0.1:6379> HTTL session:abc123 FIELDS 1 oauth_token
1) (integer) 298
127.0.0.1:6379> HTTL session:abc123 FIELDS 1 user_id
1) (integer) -1
After oauth_token expires, HGET returns nil for that field while other fields remain:
127.0.0.1:6379> HGET session:abc123 oauth_token
(nil)
127.0.0.1:6379> HGET session:abc123 user_id
"1001"
Set TTL on multiple fields at once:
127.0.0.1:6379> HEXPIRE session:abc123 600 FIELDS 2 oauth_token refresh_token
1) (integer) 1
2) (integer) 1
Python (redis-py 5.x with Redis 7.4+ server):
def set_session_tokens(session_id: str, oauth: str, refresh: str) -> None:
key = f"session:{session_id}"
r.hset(key, mapping={"oauth_token": oauth, "refresh_token": refresh})
# Field TTL — verify server >= 7.4
r.hexpire(key, 300, "oauth_token")
r.hexpire(key, 86400, "refresh_token")
def get_oauth_token(session_id: str) -> str | None:
return r.hget(f"session:{session_id}", "oauth_token")Node.js:
async function setSessionTokens(sessionId, oauth, refresh) {
const key = `session:${sessionId}`;
await redis.hset(key, { oauth_token: oauth, refresh_token: refresh });
await redis.hexpire(key, 300, "FIELDS", 1, "oauth_token");
await redis.hexpire(key, 86400, "FIELDS", 1, "refresh_token");
}Go (go-redis v9.5+ with Hexpire support):
func setSessionTokens(ctx context.Context, rdb *redis.Client, sessionID, oauth, refresh string) error {
key := "session:" + sessionID
if err := rdb.HSet(ctx, key, "oauth_token", oauth, "refresh_token", refresh).Err(); err != nil {
return err
}
if err := rdb.HExpire(ctx, key, 300*time.Second, "oauth_token").Err(); err != nil {
return err
}
return rdb.HExpire(ctx, key, 86400*time.Second, "refresh_token").Err()
}HEXPIRE is (Redis 7.4+)—check INFO server before relying on it in production. On older versions, fall back to separate string keys for volatile fields or application-side expiry checks stored in field values.
Expired hash fields behave like expired keys on access—lazy deletion on read, active expiration in background. HTTL returns -2 if the field does not exist or has expired, -1 if no TTL.
Key-level EXPIRE still applies to the entire hash—setting EXPIRE on the key deletes the whole object including fields with longer field TTLs. Avoid mixing key-level and field-level expiration unless you document precedence clearly (key deletion wins).
HPERSIST removes TTL from specific fields without affecting others.
This feature reduces key sprawl for session and token patterns previously requiring session:abc:oauth, session:abc:refresh as separate strings.
Your team debates storing user:1001 as a Redis Hash with flat fields versus one string key containing JSON—or using RedisJSON module. You need a decision framework.
Understand three tiers:
Tier 1: Native Hash (core Redis)
127.0.0.1:6379> HSET product:42 name "Widget" price "19.99" stock "100"
(integer) 3
127.0.0.1:6379> HINCRBY product:42 stock -1
(integer) 99
Best for: flat schemas, frequent partial updates, integer counters, no nested arrays.
Tier 2: JSON string in a String key
import json
def update_product_price(product_id: int, price: float) -> None:
key = f"product:{product_id}"
raw = r.get(key)
if raw is None:
return
doc = json.loads(raw)
doc["price"] = price
r.set(key, json.dumps(doc))Best for: rarely updated blobs, read-mostly cache, simple deployment without modules.
Downside: every field change reads, parses, serializes, and writes the full document—race conditions without WATCH or Lua.
Tier 3: RedisJSON (Redis Stack module)
# Requires Redis Stack / RedisJSON module
r.json().set("product:42", "$", {"name": "Widget", "price": 19.99, "tags": ["sale"]})
r.json().set("product:42", "$.price", 17.99)
r.json().arrappend("product:42", "$.tags", "featured")Best for: nested documents, array manipulation, JSONPath queries, atomic partial updates in module-aware deployments.
Python Hash vs JSON comparison:
# Hash: atomic single-field update, no parse cost
r.hset("product:42", "price", "17.99")
# JSON string: full document rewrite
doc = json.loads(r.get("product:42") or "{}")
doc["price"] = 17.99
r.set("product:42", json.dumps(doc))
# RedisJSON: path-based atomic update (module)
# r.json().set("product:42", "$.price", 17.99)| Criterion | Hash | JSON string | RedisJSON |
|---|---|---|---|
| Partial update cost | O(1) per field | O(document size) | O(path depth) |
| Nested structures | Awkward (flatten or embed JSON field) | Native | Native |
| Query by field | HGET | Load all | JSONPath |
| Deployment | Every Redis | Every Redis | Module/Stack |
| Field TTL | HEXPIRE 7.4+ | Separate keys | Key-level + workarounds |
| Client support | Universal | Universal | redis-py, go-redis extensions |
Hybrid pattern (common in production):
127.0.0.1:6379> HSET user:1001 name "Alice" email "a@example.com" login_count "42"
127.0.0.1:6379> SET user:1001:preferences '{"theme":"dark","notifications":true}'
Hot scalar fields in hash; cold JSON blob in string—read together in application layer when needed.
When JSON string wins: CMS content, API response cache, read-only config snapshots updated infrequently.
When Hash wins: Live objects with counter increments, profile fields updated independently, memory-sensitive small records.
When RedisJSON wins: Complex documents with arrays/objects, search integration (RediSearch on JSON), frequent path updates without loading full doc.
See Chapter 23 — Modules & Redis Stack for RedisJSON depth.
You need to iterate fields without HGETALL on a wide hash, or delete several fields while keeping the key.
127.0.0.1:6379> HSCAN user:1001 0 COUNT 10
1) "0"
2) 1) "name"
2) "Alice"
3) "email"
4) "alice@example.com"
127.0.0.1:6379> HDEL user:1001 avatar phone
(integer) 0
127.0.0.1:6379> HKEYS user:1001
1) "name"
2) "email"
3) "login_count"
HKEYS and HVALS are O(N)—prefer HSCAN in production.
Python:
def iter_hash_fields(key: str):
cursor = 0
while True:
cursor, data = r.hscan(key, cursor=cursor, count=50)
for field, value in data.items():
yield field, value
if cursor == 0:
breakDeleting the last field in a hash removes the key entirely. HDEL returns count of fields removed.
For atomic "replace entire object," use a Lua script: DEL + HSET in one script, or RENAME from a temp key built offline.
| Pattern | Commands |
|---|---|
| Object storage | HSET, HGET, HGETALL, HMGET |
| vs string keys | One hash vs many SETs—memory trade-off |
| Field counters | HINCRBY, HINCRBYFLOAT |
| Field TTL | HEXPIRE, HTTL, HPERSIST (7.4+) |
| vs JSON | Hash for flat/hot fields; JSON/RedisJSON for nested docs |
Hashes sit in the sweet spot between raw strings and document databases: structured enough for objects, lean enough for millions of records, and now granular enough for field expiration in Redis 7.4+. Start with hashes for entity cache; reach for RedisJSON when the schema grows teeth.
Previous: Chapter 5 — Sets · Next: Chapter 7 — Sorted Sets →
See also: Chapter 3 — Strings and Keys · Chapter 10 — Caching Patterns · Chapter 23 — Modules & Redis Stack · Redis Hashes docs