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 like HINCRBY parse and rewrite them as integers on the fly.
  • increment — for HINCRBY, 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:

  1. Redis looks up user:1001 in the keyspace’s main dictionary. If the key doesn’t exist, it creates a new hash object using the compact listpack encoding.
  2. 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.
  3. Redis checks the encoding thresholds. If the hash now exceeds hash-max-listpack-entries or hash-max-listpack-value, it converts the whole hash to a hash-table encoding for guaranteed O(1) per-field access as it grows.
  4. The command returns the count of fields that were newly created; fields that already existed and were merely updated are not counted.
  5. 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 one HSET per 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/HINCRBYFLOAT for 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/TTL apply to the whole hash key, not individual fields — don’t rely on a hash to auto-expire only part of its data.
  • Avoid HGETALL on very large hashes in hot paths; if you only need a few fields, use HMGET to fetch just those instead of transferring the whole object over the wire.
  • Use HSETNX when you want "set this field only if it isn’t already there" semantics without a separate existence check.

Practice Exercises

  1. Create a hash post:5001 representing a blog post with fields title, author, and views (start views at 0). Then, using a single atomic command, increment views by 1 without reading the current value first.
  2. Create a hash cart:user77 where 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.
  3. Create a session hash session:xyz789 with a couple of fields, give the whole key a 120-second TTL, then delete one field from the hash and confirm with TTL that 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, and HSETNX cover 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/TTL apply to the whole hash key, never to a single field, and modifying fields with HSET/HDEL does not change an existing TTL.
  • Never call plain SET on a key you’re using as a hash — it destroys the hash and replaces it with a string.
  • Prefer one multi-field HSET over many single-field calls, and use HMGET instead of HGETALL when you only need a few fields.