Hashes
A hash is a “field → value” dictionary under a single key. It is how an object is stored whole, without smearing it across a dozen keys and without re-reading all of it for one field. Below: what the structure is, where it fits, and every method in detail.
What a hash is and why
The problem. An object has to go somewhere. Spread it over separate keys —
user:42:name, user:42:email, user:42:plan — and you have three keys instead of one,
you pay Redis’s per-key overhead three times, and you lose the ability to delete the object
in one command. Put it in one key as a JSON string and you re-read and rewrite the whole
object for one field, while two simultaneous updates of different fields overwrite each
other.
The solution. A hash is a “field → value” container inside one key. A field is read and written independently of the others, the object is deleted whole in one command, and Redis spends the memory of one key rather than ten.
$cart = $store->hash('cart:42');
$cart->setAll(['qty' => '2', 'sku' => 'A-1']);
$cart->increment('qty'); // 3 — the neighbouring fields were not touched
$cart->get('sku'); // 'A-1'Properties of the structure
A hash maps strings to strings inside one Redis key: cart:42 holds the fields qty,
sku, total. The properties everything else follows from:
- Field access is O(1), no matter how many fields the hash holds.
- Values are strings, as everywhere in Redis. There is no nesting: a field cannot hold another hash or a list.
- Field order is undefined.
fields()returns them in whatever order suits the server, not in insertion order. - Fields may number up to 4,294,967,295.
Small hashes are stored in a compact representation (listpack) and take noticeably less
memory than the same data in separate keys — on current versions the threshold is 512 fields
(hash-max-listpack-entries). That is the main practical argument for a hash: a thousand
ten-field objects as hashes cost less than ten thousand separate keys.
Like a list, a hash does not need creating: writing the first field creates the key, deleting the last one removes it.
Where it is normally used
An object or a record. A user profile, a cart, an order. One field is read and updated without reading and rewriting the whole object — unlike JSON in a plain key.
Grouped counters. Views per day, metrics per endpoint, limits per client:
hash('stats:2026-08') with dates as fields. increment() is atomic at the
field level.
Settings and flags. A small set of parameters read as a whole (all()) and
changed one at a time.
Session data. Fields are read selectively, and on newer servers individual fields can have lifetimes of their own.
Where a hash is the wrong choice:
| Task | Why not a hash | What instead |
|---|---|---|
| Find objects by a field’s value | a hash is not indexed, you would scan | a secondary index on sets |
| Store a nested structure | values are strings only | serialization or separate keys |
| Sort by value | there is no order | a sorted set (ZSET) |
| Store a queue or a history | fields are unordered | a list or a stream |
| Hundreds of thousands of fields, walked often | all() pulls everything at once |
several keys or HSCAN |
Reference
RedisHash
RedisHash is a handle on one hash: the object through which commands against that key are
run. It is taken from the store:
$cart = $store->hash('cart:42'); // the server will see 'session:cart:42'hash() executes nothing — it is a view, not a request.
The prefix is applied once, here, so there is nowhere left to forget it. The handle holds no
connection: every call asks the store for the client, so it can be passed around freely
within a request.
Every method except those in The key as a whole works on fields.
The same goes for the $ttl argument: it gives a lifetime to what the call writes — the
field, not the key.
Reading
get()
Reads the value of one field.
Syntax
public function get(string $field): mixedParameters
$field — the field’s name.
Returns
The value, or null — both when the field is missing and when the hash itself is. The driver
answers false in both cases; null was chosen so “no value” does not get confused with a
stored false.
Errors
RedisCommandException — if the key is occupied by a structure of another type, a list for
instance. That is a bug in the code, not missing data, so it does not pretend to be null.
Example
$cart->get('qty'); // '2'
$cart->get('nothing'); // nullgetMany()
Reads several fields in one exchange with the server.
The completeness of the result is not a detail: if missing fields simply dropped out, the reply would not tell you which one went missing without comparing arrays.
Syntax
public function getMany(string ...$fields): arrayParameters
...$fields — field names, as many as you like. You may pass none.
Returns
A “field → value” array that always contains every requested name: missing ones arrive as
null. With no arguments — an empty array.
Example
$cart->getMany('qty', 'sku', 'promo');Result
['qty' => '2', 'sku' => 'A-1', 'promo' => null]Destructuring into variables
// three fields — one request to the server, not three
['name' => $name, 'email' => $email] = $profile->getMany('name', 'email');all()
Reads the whole hash.
Syntax
public function all(): arrayParameters
None.
Returns
A “field → value” array; an empty array for a key that does not exist.
Example
$cart->all(); // ['qty' => '2', 'sku' => 'A-1']
$missing->all(); // []This is `HGETALL`: the whole hash in one reply
fields()
Reads the field names only.
Cheaper than all() when the values are not needed: to find out which days the
counter has data for, say.
Syntax
public function fields(): arrayParameters
None.
Returns
An array of names; empty for a key that does not exist. The order is undefined.
Example
$cart->fields(); // ['qty', 'sku']values()
Reads the field values only.
Syntax
public function values(): arrayParameters
None.
Returns
An array of values; empty for a key that does not exist.
The order is undefined but it is the same as fields() gives: values line up
with names position for position, provided nothing changed between the two calls.
Example
$cart->fields(); // ['qty', 'sku']
$cart->values(); // ['2', 'A-1']has()
Checks whether a field exists.
Differs from get() !== null in that the value is not sent over the wire — noticeable for a
large value.
Syntax
public function has(string $field): boolParameters
$field — the field’s name.
Returns
true if the field exists.
count()
Reports the number of fields.
Syntax
public function count(): intParameters
None.
Returns
The number of fields; 0 for a key that does not exist. An O(1) operation: the server keeps
this number and does not recount it.
Example
if ($cart->count() === 0) {
return null; // there is no cart
}Writing
set()
Writes one field, optionally with a lifetime for that field.
With a $ttl this is a single HSETEX, not a write followed by HEXPIRE. The difference
matters: between two commands there is a gap in which the field already exists and its
lifetime does not yet — and if something breaks right there, the field stays forever.
Syntax
public function set(string $field, mixed $value, ?int $ttl = null): boolParameters
$field — the field’s name.
$value — the value. A string or a number; to write an array or an object you need a
serializer — see Configuration.
$ttl — the field’s lifetime in seconds from now. null by default — the field lives until
it is deleted.
Returns
true if the command went through.
Errors
LogicException — when $ttl is zero or negative. To remove a field there is
delete().
RedisFeatureException — if the server does not know HSETEX; see
Server version requirements.
Example
$cart->set('qty', '2');
$cart->set('lock', '1', ttl: 30); // this field disappears in half a minuteRewriting without `ttl` clears the field's lifetime
Verified against a live server: a field with a hundred-second lifetime loses it after
set('lock', '2') without a ttl and stays forever. That is how HSET works, and it is
easy to miss — updating a value looks harmless. If the lifetime is needed, set it on every
write.
setAll()
Writes several fields in one command.
Writing five fields in one call is one exchange with the server instead of five. On a hot path the difference shows.
Syntax
public function setAll(array $fields, ?int $ttl = null): boolParameters
$fields — a “field → value” array. An empty array does nothing.
$ttl — one lifetime for every field being written. null by default.
Returns
true if the command went through; true for an empty array too, since there is nothing to
write and that is not an error.
Errors
LogicException — when $ttl is zero or negative.
RedisFeatureException — if the server does not know HSETEX.
Example
$cart->setAll([
'qty' => '2',
'sku' => 'A-1',
'total' => '1990',
]);
$session->setAll(['user' => $id, 'ip' => $ip], ttl: 3600); // both fields get the lifetimeincrement()
Atomically increases a numeric field.
This is one command on the server, not “read, add, write”: two simultaneous requests cannot
both read 5 and both write 6.
Syntax
public function increment(string $field, int $by = 1): intParameters
$field — the field’s name. If the field did not exist it is created with the value 0 and
increased straight away.
$by — how much to add. 1 by default.
Returns
The field’s new value.
Errors
RedisCommandException — if the field holds something that is not a number:
$cart->set('sku', 'A-1');
$cart->increment('sku');RedisCommandException: ERR hash value is not an integerExample
$stats = $store->hash('stats:2026-08');
$stats->increment('16'); // 1 — the first view on the 16th
$stats->increment('16', 5); // 6decrement()
Atomically decreases a numeric field.
Identical to increment() in every other respect: the same atomicity, the same
creation of a missing field from zero, the same exception on a non-numeric value.
Syntax
public function decrement(string $field, int $by = 1): intParameters
$field — the field’s name.
$by — how much to subtract. 1 by default.
Returns
The field’s new value; it may go below zero.
Errors
RedisCommandException — if the field holds something that is not a number.
Example
$stats->increment('16', 6); // 6
$stats->decrement('16'); // 5
$stats->decrement('16', 2); // 3delete()
Deletes fields.
Syntax
public function delete(string ...$fields): intParameters
...$fields — field names, as many as you like. You may pass none — then no request is sent.
Returns
The number of fields that existed and were deleted.
Example
$cart->delete('promo'); // 1
$cart->delete('promo'); // 0 — it is already gone
$cart->delete('qty', 'sku', 'total'); // 3A hash with no fields stops existing
Redis does not keep empty containers: deleting the last field removes the key too. Check it
the same way you check for presence — count() === 0.
Field lifetimes
A separate lifetime per field is a capability of newer Redis releases (see below). It earns its keep where part of the data is temporary: a lock inside an object, a one-time code in a session, a cache layered over a record.
ttl()
Reports how long a field has left to live.
Syntax
public function ttl(string $field): ?intParameters
$field — the field’s name.
Returns
The remaining seconds, or null — both when the field has no lifetime and when the field
itself is missing. If telling those apart matters, ask has().
Errors
RedisFeatureException — if the server does not know HTTL.
Example
$cart->set('lock', '1', ttl: 60);
$cart->ttl('lock'); // 60
$cart->ttl('sku'); // null — the neighbouring field has no lifetime
$cart->ttl('nothing'); // null — and this field does not existpersist()
Clears a field’s lifetime, leaving the field in place.
Syntax
public function persist(string $field): boolParameters
$field — the field’s name.
Returns
true if a lifetime was cleared; false if there was nothing to clear — the field had no
lifetime, or there is no field.
Errors
RedisFeatureException — if the server does not know HPERSIST.
Example
$session->set('code', $otp, ttl: 300);
$session->persist('code'); // true — the code is confirmed, let it stay
$session->persist('code'); // false — there is no lifetime any moreThe key as a whole
The methods in this section treat the hash as one key. That is why Key is in their names:
ttl('lock') is about a field, keyTtl() is about the whole hash.
Key and field lifetimes are independent: the key may have its own, individual fields may have their own shorter ones.
expireKey()
Gives the whole hash a lifetime.
Syntax
public function expireKey(int $seconds): boolParameters
$seconds — in how many seconds to delete the hash entirely. Counted from now:
expireKey(time() + 86400) asks for fifty-six years and says nothing about it.
Returns
true if the lifetime was set; false if the key does not exist.
Example
$cart->setAll(['qty' => '1']);
$cart->expireKey(86400); // an abandoned cart lives for a daykeyTtl()
Reports how long the whole hash has left to live.
Syntax
public function keyTtl(): ?intParameters
None.
Returns
The remaining seconds, or null — both when there is no lifetime and when there is no key.
deleteKey()
Deletes the hash with all of its fields.
Syntax
public function deleteKey(): boolParameters
None.
Returns
true if the key existed.
Example
$cart->deleteKey(); // true
$cart->deleteKey(); // false — already gonename()
Returns the key’s name with the prefix — the one the server sees.
Needed for commands the handle does not wrap: those run through
raw(), and the key has to be given there in full.
Syntax
public function name(): stringParameters
None.
Returns
The full key name.
Example
$cursor = null;
$store->raw()->hScan($cart->name(), $cursor, '*', 100); // walking a large hashServer version requirements
| What | From which version |
|---|---|
| Everything except field lifetimes | any |
ttl(), persist() |
Redis 7.4 (HTTL, HPERSIST) |
set() and setAll() with a ttl |
Redis 8.0 (HSETEX) |
On an older server these methods throw a RedisFeatureException with a precise
explanation — which command, which version it needs, which one is running, and what to do
instead:
RedisFeatureException: Redis HSETEX (per-field lifetimes) needs server 8.0 or newer;
this server reports 7.2.4. Give the whole hash a lifetime with expireKey() instead.Why a refusal rather than a fallback
On 7.4 it could fall back to HSET + HEXPIRE. Then behaviour would depend on the server
version, and the gap between two commands would be back — showing up one day in production
rather than in front of the developer. One way that either works or refuses honestly is more
dependable than two similar ones.
The server version is only asked for at the moment of refusal, so ordinary work pays nothing for this message.
Mapping to Redis commands
| Method | Command |
|---|---|
get() / getMany() / all() |
HGET / HMGET / HGETALL |
fields() / values() |
HKEYS / HVALS |
has() / count() |
HEXISTS / HLEN |
set() / setAll() |
HSET, with a lifetime HSETEX |
increment() / decrement() |
HINCRBY |
delete() |
HDEL |
ttl() / persist() |
HTTL / HPERSIST |
expireKey() / keyTtl() / deleteKey() |
EXPIRE / TTL / DEL |
What is not here — HSCAN, HRANDFIELD, HINCRBYFLOAT, HSETNX — is reachable through
raw() together with name().
Next
- Lists — queues and blocking reads
- Streams — an event log and consumer groups
- Stores — strings, counters,
raw()and transactions - Configuration — serializing values