Modeling Objects with Hashes
A Redis hash is a single key that maps together several field-value pairs, like a miniature object or record living inside the Redis keyspace. Instead of scattering a user’s name, email, and age across three separate top-level keys, you store them as fields inside one hash — user:1001 — which makes the object easy to create, read, and update as a unit. Hashes are the natural fit whenever your data looks like "this key has several named properties", and they are one of the most commonly used Redis structures in real applications for exactly that reason.
Overview: How Hashes Work
Internally, a Redis hash is a field-to-value map attached to a single key. When you create a small hash — one with a modest number of fields and short values — Redis stores it using a compact, memory-efficient encoding called a listpack (the successor to the older ziplist), which packs all the fields and values into one contiguous block of memory. For a small number of fields, scanning a tightly packed array is faster and uses far less memory than the pointer and bucket overhead of a real hash table. Once a hash grows past the configured thresholds — by default more than 128 fields (hash-max-listpack-entries) or any single value longer than 64 bytes (hash-max-listpack-value) — Redis transparently converts it to a real hash table encoding, trading some memory for guaranteed O(1) field access no matter how large the hash gets. You don’t do anything to trigger this; it happens automatically and is invisible to your application except through memory usage.
Because Redis is single-threaded, every individual hash command — HSET, HGET, HINCRBY, and so on — runs to completion before the next command starts. A multi-field HSET user:1001 name "Ada" age 28 either sets both fields or, if it fails validation, none of them; no other client can ever observe the hash half-updated. This makes hashes ideal for representing objects that multiple parts of your application read and write concurrently, since you don’t need external locking to keep an object internally consistent.
Hashes sit between two less convenient alternatives. You could store an object as one JSON string (SET user:1001 '{"name":"Ada"}'), but then reading or updating a single field means fetching, deserializing, mutating, and rewriting the entire blob — wasteful and non-atomic. Or you could flatten each property into its own top-level key (user:1001:name, user:1001:email), but then there is no single key representing "the object", no way to delete or inspect it in one call, and no way to give it a single TTL. A hash gives you the atomic, one-key benefits of a string with the per-field addressability of separate keys.
One important detail: expiration (EXPIRE/TTL) applies to the whole hash key, not to individual fields. Adding, updating, or deleting a field with HSET/HDEL never touches the key’s TTL — it stays exactly as it was. There is no way to make one field of a hash expire independently of the others using the core hash commands.
Syntax
The general forms for writing and reading a hash:
HSET key field value [field value ...]
HGET key field
HMGET key field [field ...]
HGETALL key
HDEL key field [field ...]
HEXISTS key field
HINCRBY key field increment
HSETNX key field value
HKEYS key
HVALS key
HLEN key
key— the hash’s key name, e.g.user:1001; conventionally colon-namespaced.field— the property name inside the hash, e.g.name,email,stock.value— the string stored for that field. Redis always stores hash values as strings, even when they look numeric; commands likeHINCRBYparse and rewrite them as integers on the fly.increment— forHINCRBY, a positive or negative integer added to the field’s current numeric value.
| Command | Purpose | Time Complexity |
|---|---|---|
HSET |
Set one or more fields; creates the hash if it doesn’t exist | O(1) per field, O(N) for N fields in one call |
HGET |
Get the value of one field | O(1) |
HMGET |
Get several fields at once (nil for any missing) | O(N), N = fields requested |
HGETALL |
Get every field and value | O(N), N = fields in the hash |
HDEL |
Remove one or more fields | O(N), N = fields removed |
HEXISTS |
Check whether a field exists | O(1) |
HINCRBY |
Atomically increment/decrement an integer field | O(1) |
HSETNX |
Set a field only if it doesn’t already exist | O(1) |
HKEYS / HVALS |
List all field names / all values | O(N) |
HLEN |
Count the fields in a hash | O(1) |
Examples
Example 1: Creating and reading a user object
HSET user:1001 name "Ada Lovelace" email "ada@example.com" age 28
HGETALL user:1001
HGET user:1001 email
HLEN user:1001
Output:
(integer) 3
1) "name"
2) "Ada Lovelace"
3) "email"
4) "ada@example.com"
5) "age"
6) "28"
"ada@example.com"
(integer) 3
HSET creates the hash and reports 3 because three new fields were added. HGETALL returns every field immediately followed by its value, in insertion order for a small listpack-encoded hash like this one. HGET fetches a single field directly without transferring the rest of the object, and HLEN confirms the hash holds three fields — all without ever touching a second key.
Example 2: Updating fields and using an atomic counter
HSET product:2001 name "Wireless Mouse" price 1999 stock 50
HINCRBY product:2001 stock -5
HMGET product:2001 name price stock
HSETNX product:2001 name "Should Not Change"
HGET product:2001 name
Output:
(integer) 3
(integer) 45
1) "Wireless Mouse"
2) "1999"
3) "45"
(integer) 0
"Wireless Mouse"
After five units sell, HINCRBY product:2001 stock -5 atomically decrements the stock field from 50 to 45 in one step — no separate GET-then-SET round trip is needed, so there is no window where a concurrent sale could read a stale count. HMGET fetches exactly the three requested fields, in the order asked for. HSETNX refuses to overwrite name because it already exists, returning 0 instead of changing anything, which the final HGET confirms.
Example 3: A hash with a TTL — expiration applies to the whole key
HSET session:abc123 user_id 1001 role "admin" login_time 1699999999
EXPIRE session:abc123 3600
TTL session:abc123
HEXISTS session:abc123 role
HDEL session:abc123 role
HEXISTS session:abc123 role
TTL session:abc123
Output:
(integer) 3
(integer) 1
(integer) 3600
(integer) 1
(integer) 1
(integer) 0
(integer) 3600
After setting a one-hour TTL on the whole session:abc123 key, HEXISTS confirms the role field is present, and HDEL removes just that one field, leaving user_id and login_time untouched. The important detail is the final TTL call: it still reports roughly 3600, proving that deleting a field from a hash never resets or clears the key’s expiration. Only operations that replace or remove the whole key affect its TTL.
How It Works Step by Step
When you run HSET user:1001 name "Ada Lovelace" email "ada@example.com" age 28, Redis does the following, all within that one command’s atomic execution on the main thread:
- Redis looks up
user:1001in the keyspace’s main dictionary. If the key doesn’t exist, it creates a new hash object using the compact listpack encoding. - For each field/value pair in the command, Redis checks whether the field already exists in the listpack (or hash table, once converted) and either updates it in place or appends a new entry.
- Redis checks the encoding thresholds. If the hash now exceeds
hash-max-listpack-entriesorhash-max-listpack-value, it converts the whole hash to a hash-table encoding for guaranteed O(1) per-field access as it grows. - The command returns the count of fields that were newly created; fields that already existed and were merely updated are not counted.
- If AOF is enabled, the write is appended to the log, and the key is marked dirty so the next RDB snapshot includes it.
Reading with HGETALL is simpler: Redis walks the listpack (or hash table) and returns every field followed by its value as one flat array. There is no locking and no partial read, because no other command can run concurrently on the single-threaded server.
Common Mistakes
Mistake 1: Flattening one object into many top-level keys
SET user:1001:name "Ada Lovelace"
SET user:1001:email "ada@example.com"
SET user:1001:age 28
Output:
OK
OK
OK
This runs without error, but it is a design mistake: there is no single key that represents "the user", so you cannot delete, inspect, or expire the object atomically — you’d need three separate calls and risk leaving orphaned fields behind if one fails partway. Model the object as one hash instead:
HSET user:1001 name "Ada Lovelace" email "ada@example.com" age 28
Output:
(integer) 3
Mistake 2: Overwriting a hash with a plain SET
HSET config:app version "1.0" env "production"
SET config:app "just a string now"
TYPE config:app
HGETALL config:app
Output:
(integer) 2
OK
string
(error) WRONGTYPE Operation against a key holding the wrong kind of value
A plain SET replaces the key entirely, whatever type it used to hold — it silently destroys the whole hash and every field in it. After that, any hash command against the key fails with WRONGTYPE because the key is now a string. If you want to update one field, use HSET key field value; never call SET on a key you’re using as a hash.
Mistake 3: Trying to expire a single hash field
HSET user:1001 email "ada@example.com"
EXPIRE user:1001 email 60
Output:
(integer) 1
(error) ERR Unsupported option email
EXPIRE only ever takes a key and a number of seconds (plus optional NX/XX/GT/LT flags) — passing a field name as if you could expire just that one property is not valid syntax, and it wouldn’t make sense anyway: TTL is a property of the key, not of a field inside it. EXPIRE always applies to the entire hash:
HSET user:1001 email "ada@example.com"
EXPIRE user:1001 60
TTL user:1001
Output:
(integer) 1
(integer) 1
(integer) 60
If you truly need individual fields to expire on different schedules, store each of those fields as its own key instead of packing them into one hash.
Best Practices
- Use a hash whenever a key naturally has several named properties — user profiles, product records, session data, configuration objects.
- Set every field of a new object in a single
HSET key f1 v1 f2 v2 ...call instead of oneHSETper field — it’s one round trip and one atomic write instead of many. - Keep hashes reasonably sized (well under the default 128-field / 64-byte-value listpack thresholds when practical) for the best memory efficiency; larger hashes still work but cost more memory once converted to hash-table encoding.
- Use
HINCRBY/HINCRBYFLOATfor counters stored in a hash instead of reading a field, incrementing it in your application, and writing it back — that read-modify-write pattern is not atomic and loses updates under concurrency. - Remember
EXPIRE/TTLapply to the whole hash key, not individual fields — don’t rely on a hash to auto-expire only part of its data. - Avoid
HGETALLon very large hashes in hot paths; if you only need a few fields, useHMGETto fetch just those instead of transferring the whole object over the wire. - Use
HSETNXwhen you want "set this field only if it isn’t already there" semantics without a separate existence check.
Practice Exercises
- Create a hash
post:5001representing a blog post with fieldstitle,author, andviews(startviewsat 0). Then, using a single atomic command, incrementviewsby 1 without reading the current value first. - Create a hash
cart:user77where each field is a product ID (e.g.sku:2001) and each value is the quantity in the cart. Add two products, then remove one entirely using the appropriate command, and confirm afterward that the field is really gone. - Create a session hash
session:xyz789with a couple of fields, give the whole key a 120-second TTL, then delete one field from the hash and confirm withTTLthat the expiration time is unaffected by the field deletion.
Summary
- A Redis hash stores multiple field-value pairs under a single key — the natural way to model an object.
- Small hashes use a compact listpack encoding; Redis automatically converts to a hash table once a hash exceeds the configured field-count or value-size thresholds.
HSET,HGET,HMGET,HGETALL,HDEL,HEXISTS,HINCRBY, andHSETNXcover the core operations, most of them O(1) or O(N) in the number of fields touched.- Every hash command is atomic thanks to Redis’s single-threaded execution model — no partial updates are ever visible to other clients.
EXPIRE/TTLapply to the whole hash key, never to a single field, and modifying fields withHSET/HDELdoes not change an existing TTL.- Never call plain
SETon a key you’re using as a hash — it destroys the hash and replaces it with a string. - Prefer one multi-field
HSETover many single-field calls, and useHMGETinstead ofHGETALLwhen you only need a few fields.
