Hashes
A hash is a “field → value” dictionary under one key. It is how you keep an object whole instead of scattering it across a dozen keys or re-reading all of it to get one field. Below: what the structure is, where it belongs, and every method.
What a hash is
A hash maps strings to strings inside a single Redis key: cart:42 holds the fields
qty, sku, total. The properties that everything else follows from:
- Access to a field is O(1), however 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 the order they were added. - Fields go up to 4,294,967,295.
Small hashes are stored in a compact representation (listpack) and take noticeably
less memory than the same data spread over separate keys — on current versions the
threshold is 512 fields (hash-max-listpack-entries). That is the practical argument for
hashes: a thousand ten-field objects as hashes cost less than ten thousand separate keys.
Like a list, a hash needs no creating: writing the first field creates the key, deleting the last one removes it.
Where hashes belong
An object or a record. A user profile, a cart, an order. One field can be 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 per field.
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 since Redis 8.0 individual fields can also carry their own lifetime.
Where a hash is the wrong choice:
| The 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) |
| Keep a queue or a history | fields are unordered | a list or a stream |
| Hundreds of thousands of fields, often walked | all() pulls everything at once |
several keys or HSCAN |
The handle
$cart = $store->hash('cart:42'); // the server sees 'session:cart:42'hash() executes nothing — it is a view, not a request. The prefix is applied here,
once, 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 under “The key as a whole” works with fields. The same goes
for the ttl argument: it gives a lifetime to what the call writes — that is, to a field.
Method reference
Reading
get()
public function get(string $field): mixedThe value of one field.
| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
Returns the value, or null — both when the field is absent and when the hash is.
The driver reports false in both cases; null is chosen so “no value” cannot be
confused with a stored false.
Throws RedisCommandException when the key holds a structure of another type — a
list, say. That is a bug in the code rather than missing data, so it does not disguise
itself as null.
$cart->get('qty'); // '2'
$cart->get('nothing'); // nullgetMany()
public function getMany(string ...$fields): arraySeveral fields in one round trip.
| Argument | Type | Default | What it does |
|---|---|---|---|
...$fields |
string |
— | field names; none is allowed |
Returns a “field → value” array that always contains every name asked for:
missing ones come back as null. With no arguments it returns an empty array.
$cart->getMany('qty', 'sku', 'promo');
// ['qty' => '2', 'sku' => 'A-1', 'promo' => null]That completeness is not a detail: if missing fields simply dropped out, the reply would not say which one went missing without comparing arrays.
// three fields — one request to the server, not three
['name' => $name, 'email' => $email] = $profile->getMany('name', 'email');all()
public function all(): arrayThe whole hash. No arguments.
Returns a “field → value” array; for a missing key, an empty array.
$cart->all(); // ['qty' => '2', 'sku' => 'A-1']
$missing->all(); // []This is HGETALL: the whole hash in one reply
Redis is single-threaded, so assembling a reply for a hash of hundreds of thousands of
fields blocks the entire server, and the result then lands in this process’s memory. For
hashes that large take what you need with getMany(), or walk them in batches with
HSCAN through raw().
fields() and values()
public function fields(): array
public function values(): arrayField names only, or values only. No arguments. Return an array; empty for a missing key.
The order is undefined but identical between the two — values line up with names, position by position.
$cart->fields(); // ['qty', 'sku']
$cart->values(); // ['2', 'A-1']fields() is cheaper than all() when the values are not needed — to learn which days a
counter holds, for instance.
has()
public function has(string $field): bool| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
Returns true when the field exists. It differs from get() !== null in that the
value never crosses the network — which for a large value is noticeable.
count()
public function count(): intThe number of fields. No arguments. Returns the count, 0 for a missing key. O(1).
if ($cart->count() === 0) {
return null; // there is no cart
}Writing
set()
public function set(string $field, mixed $value, ?int $ttl = null): boolWrites one field, optionally with a lifetime for that field.
| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
$value |
mixed |
— | the value. A string or a number; anything else needs a serializer |
$ttl |
?int |
null |
the field’s lifetime in seconds from now. null means none |
Returns true when the command ran. Throws LogicException for a ttl of zero
or less (that is a caller mistake: to remove a field there is delete()), and
RedisFeatureException on servers older than 8.0 — see
Server version requirements.
$cart->set('qty', '2');
$cart->set('lock', '1', ttl: 30); // this field disappears in half a minuteRewriting without a ttl drops the field's lifetime
Verified against a live server: a field with a 100-second lifetime loses it after
set('x', '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 matters, set it on every
write.
With a $ttl this is a single HSETEX, not a write followed by HEXPIRE. The
difference matters: between two commands there is a window in which the field already
exists but has no expiry — and if anything fails there, the field stays forever.
setAll()
public function setAll(array $fields, ?int $ttl = null): boolWrites several fields in one command.
| Argument | Type | Default | What it does |
|---|---|---|---|
$fields |
array |
— | a “field → value” array. An empty one does nothing |
$ttl |
?int |
null |
one lifetime for every field written |
Returns true when the command ran; for an empty array it is true as well, since
there is nothing to write and that is not a failure.
$cart->setAll([
'qty' => '2',
'sku' => 'A-1',
'total' => '1990',
]);
$session->setAll(['user' => $id, 'ip' => $ip], ttl: 3600); // both fields expireWriting five fields with one setAll() is one round trip instead of five. On a hot path
that shows.
increment() and decrement()
public function increment(string $field, int $by = 1): int
public function decrement(string $field, int $by = 1): intChange a numeric field atomically.
| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
$by |
int |
1 |
how much to change it by |
Return the field’s new value. A field that did not exist is created at 0 and then
changed. Throw RedisCommandException when the field holds something that is not a
number.
$stats = $store->hash('stats:2026-08');
$stats->increment('16'); // 1 — the first view on the 16th
$stats->increment('16', 5); // 6
$stats->decrement('16'); // 5This is one server-side command, not “read, add, write”: two concurrent requests cannot
both read 5 and both write 6.
$cart->set('sku', 'A-1');
$cart->increment('sku'); // RedisCommandException: hash value is not an integerSuch a call used to return 0 — a refusal that looked like “the counter reset”. Now it
is an exception.
delete()
public function delete(string ...$fields): intRemoves fields.
| Argument | Type | Default | What it does |
|---|---|---|---|
...$fields |
string |
— | field names; none is allowed |
Returns how many of them existed and were removed. With no arguments it is 0 and no
request is sent.
$cart->delete('promo'); // 1
$cart->delete('promo'); // 0 — already gone
$cart->delete('qty', 'sku', 'total'); // 3A hash with no fields stops existing
Redis keeps no empty containers: deleting the last field deletes the key. You check for
it the same way you check emptiness — count() === 0.
Field lifetimes
A lifetime per field is a feature of recent Redis versions (see below). It is useful where part of the data is temporary: a lock inside an object, a one-time code in a session, a cache over a record.
ttl()
public function ttl(string $field): ?int| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
Returns the remaining seconds, or null — both when the field has no lifetime and
when there is no such field. If telling those apart matters, ask has().
$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 there is no such fieldpersist()
public function persist(string $field): boolDrops a field’s lifetime, leaving the field itself in place.
| Argument | Type | Default | What it does |
|---|---|---|---|
$field |
string |
— | the field name |
Returns true when a lifetime was removed, false when there was nothing to remove
— the field had no lifetime, or there is no such field.
$session->set('code', $otp, ttl: 300);
$session->persist('code'); // true — the code was confirmed, let it stay
$session->persist('code'); // false — there is no lifetime leftThe key as a whole
The methods here treat the hash as a single key. That is exactly why Key is in their
names: ttl('lock') is about a field, keyTtl() about the whole hash.
expireKey()
public function expireKey(int $seconds): bool| Argument | Type | Default | What it does |
|---|---|---|---|
$seconds |
int |
— | after how many seconds to delete the whole hash |
Returns true when the lifetime was set, false when there is no such key.
$cart->setAll(['qty' => '1']);
$cart->expireKey(86400); // an abandoned cart lives a dayThe lifetime counts from now: expireKey(time() + 86400) asks for fifty-six years and
says nothing about it.
keyTtl()
public function keyTtl(): ?intNo arguments. Returns the key’s remaining seconds, or null — both when there is no
lifetime and when there is no key.
The key’s lifetime and the fields’ are independent: a key may have one while individual fields have their own, shorter ones.
deleteKey()
public function deleteKey(): boolDeletes the hash with all its fields. No arguments. Returns true when the key
existed.
name()
public function name(): stringThe key name with the prefix — what the server sees. Needed for commands the handle does not wrap:
$cursor = null;
$store->raw()->hScan($cart->name(), $cursor, '*', 100); // walking a large hashServer version requirements
| What | Since |
|---|---|
| Everything except field lifetimes | any version |
ttl(), persist() |
Redis 7.4 (HTTL, HPERSIST) |
set(ttl:), setAll(ttl:) |
Redis 8.0 (HSETEX) |
On an older server those three throw RedisFeatureException with an exact explanation:
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 and not a fallback
On 7.4 it could fall back to HSET + HEXPIRE. Behaviour would then depend on the
server version, and the window between two commands would be back — to show up one day in
production rather than in front of the developer. One way that either works or refuses
honestly is sturdier than two similar ones.
The server version is only looked up at the moment of refusal, so ordinary work pays nothing for the better 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 available 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 — value serialization