What Is a Redis Hash? Commands, TTL & Java Examples
A Redis hash is a data structure that stores a set of field-value pairs under a single key. It is the natural way to represent an object — a user, a product, a session — where each attribute is a separate field you can read or write independently, without fetching or rewriting the whole record.
Hashes work identically in Valkey, the open-source fork of Redis, and every command and example on this page applies to both.
What Are Redis Hashes?
A Redis hash implements the hash table (or hash map) abstract data type. One Redis key holds the hash itself; inside it, each field name maps to a value.
Take a user record. Instead of scattering the data across several keys, you store it in one hash:
user:1001
├── name → "Ada Lovelace"
├── email → "ada@example.com"
├── role → "engineer"
└── logins → "42"
Two levels of naming are at play here, and it's worth keeping them straight. user:1001 is the Redis key — the thing EXISTS, EXPIRE, and DEL operate on. name and email are fields inside that key, addressed by hash commands like HGET and HSET.
Why a Hash Instead of Separate Keys?
You could store the same record as four string keys: user:1001:name, user:1001:email, and so on. A hash is usually better for three reasons.
Memory. Every Redis key carries overhead — the key name, expiry metadata, and a slot in the top-level dictionary. A small hash stores its fields in a single compact structure, so one hash with four fields costs considerably less than four separate keys.
Cluster locality. This is the one that bites in production. Separate keys hash to different slots, so in Redis Cluster a multi-key MGET or MSET across them fails with a CROSSSLOT error unless you wrap every key in the same hash tag. A hash is one key, so it is always one slot, and HMGET across its fields always works.
Lifecycle. One key means one EXPIRE, one DEL, one RENAME. The whole record lives and dies together instead of leaving orphaned fragments behind when part of a write fails.
The counterargument is scale: a hash with millions of fields becomes a single large key that can't be split across cluster nodes and makes some operations expensive. We cover both problems below.
How Redis Hashes Work
A Redis hash is flat. Field names are strings, values are strings, and there is no nesting — you cannot put a hash inside a hash. If you need nested or structured documents, that's a job for JSON, not a hash.
"String" is broader than it sounds. A Redis string can hold up to 512 MB of any bytes: text, numbers, images, or serialized objects. A single hash can hold up to 4,294,967,295 (2³² − 1) field-value pairs.
Encoding: Why Small Hashes Are Cheap
Redis stores hashes two different ways depending on their size, and this is what people mean when they call hashes space-efficient.
A small hash is encoded as a listpack — a single contiguous, serialized blob. There's no per-field pointer overhead and no hash table to allocate, so memory usage is very low. Lookups are a linear scan, but across a handful of fields that's faster in practice than hashing, because the whole thing sits in a few cache lines.
Once a hash outgrows either of two configurable thresholds, Redis converts it to a real hash table:
| Setting | Converts when |
|---|---|
hash-max-listpack-entries | The hash holds more fields than this limit |
hash-max-listpack-value | Any single field name or value exceeds this size, in bytes |
Don't assume the defaults. They differ across Redis versions and, more importantly, managed services ship their own — AWS MemoryDB, for example, defaults hash-max-listpack-entries to 512 rather than the smaller value vanilla Redis uses. Check the server you're actually running against:
> CONFIG GET hash-max-listpack-entries
> CONFIG GET hash-max-listpack-value
And confirm what a given key is doing with OBJECT ENCODING, which returns either listpack or hashtable:
> OBJECT ENCODING user:1001
"listpack"
After conversion, lookups become O(1) but memory per field rises substantially. The conversion is one-way at runtime — a hash that grows past the threshold and then shrinks back stays a hash table until the key is deleted, or until the dataset is reloaded from disk and re-encoded on restart.
The practical takeaway: many small hashes are dramatically cheaper than a few large ones. If you're storing millions of records, one hash per record keeps every one of them in the compact encoding. On Redis before 7.0 these settings were named hash-max-ziplist-entries and hash-max-ziplist-value, and the underlying encoding was a ziplist.
Redis Hash Commands
Every hash command begins with H. The complexity column is the part worth internalising — it's the difference between a hash that scales and one that stalls your server.
| Command | What it does | Complexity | Redisson equivalent |
|---|---|---|---|
HSET | Sets one or more fields | O(N) in fields set | map.put(), map.fastPut(), map.putAll() |
HSETNX | Sets a field only if it doesn't exist | O(1) | map.putIfAbsent(), map.fastPutIfAbsent() |
HGET | Reads one field | O(1) | map.get() |
HMGET | Reads several fields at once | O(N) in fields requested | map.getAll(Set) |
HDEL | Deletes one or more fields | O(N) in fields deleted | map.remove(), map.fastRemove() |
HEXISTS | Tests whether a field exists | O(1) | map.containsKey() |
HLEN | Counts fields | O(1) | map.size() |
HSTRLEN | Length of a field's value | O(1) | map.valueSize() |
HGETALL | Returns every field and value | O(N) in hash size | map.readAllEntrySet() |
HKEYS | Returns every field name | O(N) | map.readAllKeySet() |
HVALS | Returns every value | O(N) | map.readAllValues() |
HINCRBY | Atomically adds to a numeric field | O(1) | map.addAndGet() |
HINCRBYFLOAT | The same, for floating-point values | O(1) | map.addAndGet() |
HRANDFIELD | Returns random field(s) | O(N) in count requested | map.randomKeys(), map.randomEntries() |
HSCAN | Iterates fields with a cursor | O(1) per call | iterating map.keySet() / map.entrySet() |
HEXPIRE / HPEXPIRE | Sets a TTL on individual fields | O(N) in fields | RMapCacheNative with TTL |
HTTL / HPERSIST | Reads or clears a field's TTL | O(N) in fields | RMapCacheNative.expireEntry() |
HMSET still works but has been deprecated since Redis 4.0 — HSET accepts multiple field-value pairs and should be used instead. HRANDFIELD arrived in Redis 6.2, and the HEXPIRE family in Redis 7.4.
HGETALL vs HSCAN on Large Hashes
The bolded rows above are the ones that cause production incidents.
Redis executes commands on a single thread. HGETALL is O(N), so calling it on a hash with a million fields blocks that thread for the entire read — every other client waits, and the serialized reply may be large enough to strain the output buffer and the network on top of it.
HSCAN solves this by returning a cursor. Each call fetches a small batch and hands back a position to resume from, so the work is spread across many short operations that interleave with other traffic:
HSCAN user:1001 0 COUNT 100
The trade-off is that HSCAN gives no point-in-time snapshot: fields added or removed mid-iteration may or may not appear, though fields present for the whole scan are guaranteed to be returned. Redis 7.4 added a NOVALUES option for when you only need field names.
Rule of thumb: HGETALL is fine for a record with a bounded number of fields — a user profile, a config object. For anything unbounded, iterate with HSCAN, or fetch only the fields you need with HMGET.
For a deeper treatment, see HGETALL vs HSCAN: reading large Redis hashes without blocking the server.
Redis Hash Example
Here's a complete walkthrough from redis-cli. Start by creating a hash with several fields in one call:
> HSET user:1001 name "Ada Lovelace" email "ada@example.com" role "engineer"
(integer) 3
The reply is the number of new fields created. Setting an existing field returns 0, because it updated rather than added.
Read a single field:
> HGET user:1001 role
"engineer"
Read several at once:
> HMGET user:1001 name email
1) "Ada Lovelace"
2) "ada@example.com"
Fetch the whole record:
> HGETALL user:1001
1) "name"
2) "Ada Lovelace"
3) "email"
4) "ada@example.com"
5) "role"
6) "engineer"
Counters live happily inside a hash, and HINCRBY is atomic — no read-modify-write race, even with many clients:
> HINCRBY user:1001 logins 1
(integer) 1
> HINCRBY user:1001 logins 1
(integer) 2
Check and remove fields:
> HEXISTS user:1001 role
(integer) 1
> HDEL user:1001 role
(integer) 1
> HLEN user:1001
(integer) 3
Deleting the last field deletes the key itself — Redis does not keep empty hashes.
Can Individual Hash Fields Expire?
Yes, since Redis 7.4 and Valkey 9.0. Before that, TTL applied only to whole keys, and per-field expiry had to be faked with a companion sorted set of timestamps and a sweeper job.
The HEXPIRE family removes that workaround:
> HEXPIRE user:1001 3600 FIELDS 1 session_token
1) (integer) 1
> HTTL user:1001 FIELDS 1 session_token
1) (integer) 3600
> HPERSIST user:1001 FIELDS 1 session_token
1) (integer) 1
HEXPIRE sets a TTL in seconds, HPEXPIRE in milliseconds, and HEXPIREAT / HPEXPIREAT take absolute timestamps. HTTL reports the remaining time and HPERSIST removes the expiry, leaving the field permanent.
Note the shape of these commands: the field list comes last, after a FIELDS keyword and a count, and the reply is an array with one status code per field rather than a single value. They also accept the NX, XX, GT, and LT conditions, so you can set an expiry only if the field has none, or only if the new TTL is longer than the existing one.
Expiration is handled by the server. When a field lapses it is removed on its own, with no sweeper task and no client-side bookkeeping — which matters for session data, cached credentials, and rate-limit windows stored alongside more permanent fields in the same record.
When to Use a Hash
| What you're storing | Use | Why |
|---|---|---|
| A single value or serialized object under one key | String | No field structure needed |
| An entity whose fields are read and written independently | Hash | Field-level access without rewriting the record |
| A collection of unique members | Set | Membership tests and set algebra |
| A nested document you update by path | JSON | Hashes are flat and cannot nest |
| Items ranked by a numeric score | Sorted set | Ordering and range queries |
| A huge, unbounded collection of pairs | Many keys or many hashes | One giant hash can't be split across cluster nodes |
HSET vs SET
These are frequently confused because the names suggest a relationship that doesn't exist.
SET key value stores a single string at a key. HSET key field value stores a field inside a hash at that key. They are different data types, and the commands are not interchangeable — you cannot GET a hash or HGET a string. Redis returns a WRONGTYPE error if you try.
SET is right when the key holds one thing. HSET is right when the key holds a record with parts you want to address separately.
HSET vs SADD
A different collision, worth clearing up: Redis also has a set type, manipulated with SADD, SREM, and SISMEMBER. A set is an unordered collection of unique values with no field names attached. If you're looking for Java's HashSet semantics, that's a Redis set, not a hash. If you're looking for HashMap semantics, you want a hash.
Redis Hashes in Java With Redisson
Redis has no native Java API — it speaks its own wire protocol over a socket, so Java applications need a client library. Redisson is a Redis and Valkey Java client that goes further than wrapping commands: it exposes a Redis hash as RMap, a distributed implementation of java.util.concurrent.ConcurrentMap.
The result is that ordinary Java code operates on server-side data shared by every instance of your application:
RMap<String, String> user = redisson.getMap("user:1001");
user.put("name", "Ada Lovelace"); // HSET
String role = user.get("role"); // HGET
boolean exists = user.containsKey("email"); // HEXISTS
int fields = user.size(); // HLEN
One detail to know: Redisson determines key uniqueness from a key's serialized state rather than its hashCode() and equals() methods, since the comparison happens on the server.
Skip the Round Trip You Don't Need
Map.put() is contractually required to return the previous value, which means a HGET before every HSET. When you don't need it, the fast* methods issue a single command:
user.fastPut("role", "engineer"); // HSET only, no preceding read
user.fastRemove("role"); // HDEL only
user.fastPutIfAbsent("tier", "pro"); // HSETNX
On a write-heavy path this halves the number of commands sent.
Per-Key Locking
Field-level commands are atomic on their own, but a read-modify-write across several fields is not. Redisson binds a distributed lock to an individual map key, so you can make a compound update safe without locking anything else:
RMap<String, Account> accounts = redisson.getMap("accounts");
RLock lock = accounts.getLock("acct:42");
lock.lock();
try {
Account a = accounts.get("acct:42");
a.debit(100);
accounts.put("acct:42", a);
} finally {
lock.unlock();
}
getReadWriteLock(), getSemaphore(), and getCountDownLatch() are available per key as well. This has no equivalent in the raw command set — it's the clearest reason to reach for a higher-level client over hand-rolled HGET/HSET calls.
Per-Field TTL
RMapCacheNative maps directly onto the HEXPIRE commands described above, with expiry handled server-side:
RMapCacheNative<String, String> sessions = redisson.getMapCacheNative("sessions");
// write a field with a 30-minute TTL
sessions.put("token:abc", payload, 30, TimeUnit.MINUTES);
// or set expiry on a field that already exists
sessions.expireEntry("token:abc", Duration.ofMinutes(30));
This requires Redis 7.4+ or Valkey 9.0+, since it delegates to the server's own field expiry. On older servers, RMapCache offers the same API backed by a client-side eviction task instead.
Reads That Never Leave the JVM
For read-heavy maps — pricing tables, feature flags, reference data — RLocalCachedMap keeps a near cache inside your application and uses pub/sub to invalidate it across instances when an entry changes. Reads served locally are up to 45x faster than a network round trip:
RLocalCachedMap<String, String> config =
redisson.getLocalCachedMap("config", LocalCachedMapOptions.defaults());
String flag = config.get("checkout.v2"); // no network call after the first read
Eviction policy, cache size, sync strategy, and reconnection behaviour are all configurable. See client-side caching for how the invalidation works.
Keeping a Hash in Sync With a Database
Attaching a MapLoader gives you read-through: a miss loads from your database automatically. A MapWriter gives you write-through or write-behind, with writes either synchronous or batched asynchronously:
MapOptions<String, String> options = MapOptions.<String, String>defaults()
.writer(mapWriter)
.writeMode(WriteMode.WRITE_BEHIND)
.writeBehindDelay(5000)
.writeBehindBatchSize(100);
RMap<String, String> map = redisson.getMap("users", options);
Beyond a Single Hash
Two capabilities in Redisson PRO address the large-hash problem raised earlier. Data partitioning via getClusteredMap() splits one logical map across multiple cluster master nodes, scaling memory and throughput past what a single hash slot allows. Bounded maps cap a map by entry count or byte size with LRU or LFU eviction, so a cache can't grow without limit.
If you'd rather not map fields by hand at all, the Live Object service persists an annotated POJO as a hash and back — see Redisson's Live Object Service.
Every object shown here also has asynchronous, Reactive Streams, and RxJava3 variants. Full details are in the Redisson Map documentation, and Redis data structures in Java covers how the other Redis types map onto Java interfaces.
Frequently Asked Questions
What Is the Difference Between a Redis Hash and a Redis String?
A string stores one value at a key. A hash stores many field-value pairs at a key, each readable and writable on its own. Use a string for a standalone value, a hash for a record with independently accessed fields.
How Many Fields Can a Redis Hash Hold?
Up to 4,294,967,295 (2³² − 1) field-value pairs, with each value up to 512 MB. Practical limits arrive far sooner: hashes above hash-max-listpack-entries fields lose their compact encoding, and a single hash always lives on one cluster node.
Can a Redis Hash Store Nested Objects?
No. Hashes are flat — field names and values are both strings, and a value cannot itself be a hash. Either flatten the structure into dotted field names, serialize the nested object into a single field, or use the JSON type for documents you need to update by path.
Can Individual Hash Fields Expire?
Yes, from Redis 7.4 and Valkey 9.0 onward, using HEXPIRE, HPEXPIRE, HEXPIREAT, and HPEXPIREAT. On earlier versions only the whole key can carry a TTL.
Is HGETALL Safe to Use on a Large Hash?
Not necessarily. HGETALL is O(N) and Redis is single-threaded, so on a hash with a very large number of fields it blocks every other client for the duration of the read. Use HSCAN to iterate incrementally, or HMGET to fetch only the fields you need.
What Is the Java Equivalent of a Redis Hash?
Redisson's RMap, which implements java.util.concurrent.ConcurrentMap. Call redisson.getMap("key") and use put, get, and containsKey as you would with any Java Map — the data is stored as a Redis hash on the server and shared across every application instance.
Redisson PRO adds local caching, data partitioning, and advanced eviction on top of the same API, without changing your code. Try it for free.