Hashes Explained

A Redis hash is a data type that maps string fields to string values inside a single key — like a small dictionary, or a row in a table, living under one key name. Instead of storing a user’s name, email, and age as three separate top-level keys, you store them as three fields inside one user:1001 hash. This makes hashes the natural fit for representing objects: user profiles, product records, configuration bundles, and session data, all addressable and updatable field-by-field without reading or rewriting the whole thing.

Overview / How It Works

Internally, Redis represents a hash using one of two encodings depending on its size. A small hash — by default at most 128 fields, with no individual field or value longer than 64 bytes — is stored as a listpack, a compact, memory-efficient sequence of length-prefixed entries packed together in memory. This keeps small objects, which is most hashes in a typical application (a user profile, a small config block), very cheap to store. Once a hash grows past either threshold, Redis transparently converts it to a real hash table (a dictionary), trading a bit more memory for O(1) average-case field access no matter how large the hash gets. You never choose the encoding yourself — Redis picks it automatically, and every command behaves identically either way. It is purely an internal memory-versus-speed tradeoff.

Because Redis is single-threaded, every individual hash command executes atomically with respect to every other command — nothing else can run while HSET or HINCRBY is being processed. That means you never observe a hash half-updated by two concurrent writers, and a command like HINCRBY is safe to use as a counter embedded inside a larger object without any extra application-level locking.

A hash lives under one top-level key, and that key carries a TTL exactly like a string key would: EXPIRE, TTL, and PERSIST apply to the whole hash, not to individual fields inside it. If you run a plain SET on a key that already holds a hash (or try a hash command on a key that holds a string), Redis refuses with a WRONGTYPE error rather than silently reinterpreting the data — every key has exactly one data type at a time. (Redis 7.4 and later added commands like HEXPIRE to expire individual fields within a hash; that is a more specialized, newer feature covered elsewhere — the TTL you will use for the vast majority of hashes is still the ordinary key-level EXPIRE.)

Reading a hash with HGET or HMGET for a known set of fields is cheap regardless of how big the hash is. But HGETALL, HKEYS, and HVALS are O(N) in the number of fields in the hash, because Redis has to walk and serialize every entry to answer. For a hash with a handful of fields (a typical user profile) that cost is negligible; for a hash you have let grow to tens of thousands of fields, it is not — a giant HGETALL blocks the single-threaded server for the whole walk, the same class of problem as running KEYS * against a large keyspace. If you find yourself storing a large, growing collection inside one hash, that is usually a sign you want a separate key per item, or a different structure entirely (a sorted set, if you need ordering) instead of one ever-growing hash.

Syntax

The general form of the core hash commands:

HSET key field value [field value ...]
HGET key field
HGETALL key
HDEL key field [field ...]
HMGET key field [field ...]
HINCRBY key field increment
HEXISTS key field
HLEN key
HSETNX key field value
  • key — the hash’s key name, e.g. user:1001.
  • field — the name of a field inside the hash, e.g. name or email.
  • value — the string stored for that field. Redis stores everything as a string internally; numeric-looking values are stored as their string form, and commands like HINCRBY parse and re-serialize them on each call.
  • increment — for HINCRBY, the integer amount (positive or negative) to add to a field’s current numeric value.

Time complexity of the commands used in this lesson:

Command Purpose Time Complexity
HSET Set one or more fields O(1) per field
HGET Read one field O(1)
HMGET Read several named fields O(N) for N fields requested
HGETALL Read every field and value O(N) for N fields in the hash
HDEL Remove one or more fields O(N) for N fields removed
HINCRBY Increment a numeric field O(1)
HEXISTS Check whether a field exists O(1)
HLEN Count fields in the hash O(1)
HSETNX Set a field only if absent O(1)
HKEYS / HVALS List all field names / values O(N)

Examples

Example 1: Storing and reading a user profile

The most common use of a hash is representing an object. Here we store three fields for a user and read them back both individually and all at once.

HSET user:1001 name "Ada" email "ada@example.com" age "36"
HGET user:1001 name
HGETALL user:1001

Output:

(integer) 3
"Ada"
1) "name"
2) "Ada"
3) "email"
4) "ada@example.com"
5) "age"
6) "36"

HSET returns (integer) 3 because three new fields were created on a brand-new key. HGET returns just the single requested value. HGETALL returns a flat array alternating field name, field value, field name, field value — this is the shape every Redis client library turns back into a map or dictionary for you.

Example 2: Updating a numeric field and inspecting the hash

Hashes are also useful for objects that have counters mixed in with regular attributes, like inventory stock.

HSET product:2002 name "Widget" price "9" stock "100"
HINCRBY product:2002 stock -5
HEXISTS product:2002 price
HDEL product:2002 price
HLEN product:2002

Output:

(integer) 3
(integer) 95
(integer) 1
(integer) 1
(integer) 2

HINCRBY parses the current string value of stock as an integer, adds -5, and stores the result back as a string — all atomically, with no separate read-modify-write on the client side. HEXISTS confirms price is present before HDEL removes it, and HLEN confirms the hash now has two fields left (name and stock).

Example 3: A realistic session hash with an expiration

Session data is a classic hash use case: several related fields, with a single TTL controlling when the whole session disappears.

HSET session:abc123 user_id "1001" ip "203.0.113.5" login_time "1700000000"
EXPIRE session:abc123 3600
HMGET session:abc123 user_id ip
TTL session:abc123
HSETNX session:abc123 user_id "9999"
HGET session:abc123 user_id

Output:

(integer) 3
(integer) 1
1) "1001"
2) "203.0.113.5"
(integer) 3600
(integer) 0
"1001"

EXPIRE puts a one-hour TTL on the whole session:abc123 key. HMGET reads two named fields in one round trip instead of two separate HGET calls. HSETNX (“set if not exists”) returns (integer) 0 and leaves user_id unchanged, because that field is already present — it only writes when the field is missing, which is handy for things like “only claim this field once.”

How It Works Step by Step

When you run HSET user:1001 name "Ada" against a key that does not exist yet, Redis: (1) creates a new, empty hash object for the key; (2) chooses the listpack encoding, since a one-field hash is far under the default size thresholds; (3) appends the field name and value into the listpack’s compact byte layout; (4) returns the count of fields that were newly added (as opposed to fields that already existed and were merely overwritten). Because this all happens on the single command-processing thread, no other client’s command can interleave partway through — the field either appears fully written or not at all.

As more fields are added, or if any single field or value exceeds the listpack’s per-entry size threshold, Redis converts the whole hash to a hash table encoding in one step: it walks the existing listpack entries and rebuilds them as a proper dictionary keyed by field name. From that point on, lookups by field name are true O(1) hash table lookups rather than a scan through a packed list — the conversion trades a bit more memory overhead per field for lookup speed that no longer degrades as the hash grows. This conversion is automatic and invisible to your application; you interact with the hash through the same commands regardless of which encoding backs it.

Common Mistakes

Mistake 1: Mixing a string and a hash under the same key

Every key has exactly one type. Trying to run a hash command against a key that already holds a plain string fails:

SET config:app "production"
HSET config:app timeout "30"

Output:

OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value

The fix is to use a distinct key name for the hash rather than colliding with an existing string key:

HSET config:app:settings timeout "30"
HGET config:app:settings timeout

Output:

(integer) 1
"30"

Mistake 2: Assuming HSET replaces the whole hash

Unlike a plain SET on a string key, which fully replaces the previous value, HSET only creates or overwrites the specific fields you pass — every other existing field is left untouched. Developers coming from a “set overwrites everything” mental model are sometimes surprised their other fields survive:

HSET cart:5005 item1 "apple" item2 "banana"
HSET cart:5005 item1 "orange"
HGETALL cart:5005

Output:

(integer) 2
(integer) 0
1) "item1"
2) "orange"
3) "item2"
4) "banana"

The second HSET returns (integer) 0 because item1 already existed and was only updated, not added — and item2 (banana) is still there. If you actually want to replace an object entirely, delete the key first (or delete the fields you no longer want) rather than assuming a later HSET clears it.

Mistake 3: Reaching for HGETALL on a hash that keeps growing

Because HGETALL, HKEYS, and HVALS are O(N) in the number of fields, calling them routinely on a hash you keep adding fields to (say, one field per event, forever) gets slower and blocks the server longer as the hash grows — the same underlying issue as KEYS * on a big keyspace, just scoped to one key instead of the whole database. If a “hash” is really an ever-growing collection, model it as one key per item (or a sorted set, if you need order) instead of stuffing everything into a single hash you then have to read in full.

Best Practices

  • Use hashes to represent one object per key (a user, a product, a session) — not as a substitute for a whole collection of unrelated items.
  • Prefer HMGET over several separate HGET calls when you need more than one field, to save round trips.
  • Set a TTL with EXPIRE on session- or cache-style hashes so stale objects don’t accumulate forever; remember it applies to the entire key, not per field.
  • Avoid unbounded field growth on a single hash; if the number of fields is effectively open-ended, use separate keys or another data structure instead.
  • Use HINCRBY instead of reading a field, adding in your application, and writing it back — the read-modify-write pattern is not atomic and loses updates under concurrency, while HINCRBY is a single atomic command.
  • Reserve HGETALL for hashes you know stay small; for large or unbounded hashes, prefer targeted HGET/HMGET calls or HSCAN to iterate without blocking.
  • Use a consistent, colon-namespaced key naming convention (user:1001, session:abc123) so related keys are easy to reason about and to pattern-match with SCAN.

Practice Exercises

  1. Create a hash at product:3003 with fields name, price, and stock. Increment stock by 20, then decrement it by 3 using a single atomic command each time. What is the final stock value?
  2. Store a hash at settings:theme:7 with a color field. Use HSETNX to try to set color to a new value after it already exists, and confirm the original value is unchanged. Then use plain HSET to actually change it, and confirm that one works.
  3. Create a hash at session:xyz789 with a couple of fields, give it a 60-second TTL with EXPIRE, and check the remaining time with TTL. Predict, then verify, what TTL returns for a key with no expiration set at all, and for a key that doesn’t exist.

Summary

  • A hash maps multiple field/value string pairs under one key — the natural fit for representing an object.
  • Redis stores small hashes as a compact listpack and automatically upgrades to a hash table once field count or value size passes internal thresholds; either way, commands behave identically.
  • HSET only touches the fields you specify; it never clears the rest of the hash the way SET replaces a whole string key.
  • A hash’s TTL is set on the whole key with EXPIRE, not per field, in ordinary usage.
  • HGET/HMGET/HINCRBY/HEXISTS/HLEN/HSETNX are all O(1) or proportional only to the fields you name; HGETALL/HKEYS/HVALS are O(N) in the hash’s total size, so use them with care on large hashes.
  • Mixing types under one key name always fails with WRONGTYPE — give hashes and strings distinct key names.