Redis · Hashes

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.

Package flytachi/winter-redisClass RedisHashField lifetimes Redis 7.4 / 8.0

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.

php
$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:

php
$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

php
public function get(string $field): mixed

Parameters

$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

php
$cart->get('qty');       // '2'
$cart->get('nothing');   // null

getMany()

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

php
public function getMany(string ...$fields): array

Parameters

...$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

php
$cart->getMany('qty', 'sku', 'promo');

Result

text
['qty' => '2', 'sku' => 'A-1', 'promo' => null]

Destructuring into variables

php
// three fields — one request to the server, not three
['name' => $name, 'email' => $email] = $profile->getMany('name', 'email');

all()

Reads the whole hash.

Syntax

php
public function all(): array

Parameters

None.

Returns

A “field → value” array; an empty array for a key that does not exist.

Example

php
$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 over a hash of hundreds of thousands of fields blocks the entire server, and the result then lands in the process’s memory. For such hashes take what you need with getMany(), or walk it in batches with HSCAN from raw().

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

php
public function fields(): array

Parameters

None.

Returns

An array of names; empty for a key that does not exist. The order is undefined.

Example

php
$cart->fields();    // ['qty', 'sku']

values()

Reads the field values only.

Syntax

php
public function values(): array

Parameters

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

php
$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

php
public function has(string $field): bool

Parameters

$field — the field’s name.

Returns

true if the field exists.

count()

Reports the number of fields.

Syntax

php
public function count(): int

Parameters

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

php
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

php
public function set(string $field, mixed $value, ?int $ttl = null): bool

Parameters

$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

php
$cart->set('qty', '2');
$cart->set('lock', '1', ttl: 30);     // this field disappears in half a minute

Rewriting 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

php
public function setAll(array $fields, ?int $ttl = null): bool

Parameters

$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

php
$cart->setAll([
  'qty'   => '2',
  'sku'   => 'A-1',
  'total' => '1990',
]);

$session->setAll(['user' => $id, 'ip' => $ip], ttl: 3600);   // both fields get the lifetime

increment()

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

php
public function increment(string $field, int $by = 1): int

Parameters

$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:

php
$cart->set('sku', 'A-1');
$cart->increment('sku');
text
RedisCommandException: ERR hash value is not an integer

Example

php
$stats = $store->hash('stats:2026-08');

$stats->increment('16');        // 1  — the first view on the 16th
$stats->increment('16', 5);     // 6

decrement()

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

php
public function decrement(string $field, int $by = 1): int

Parameters

$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

php
$stats->increment('16', 6);     // 6
$stats->decrement('16');        // 5
$stats->decrement('16', 2);     // 3

delete()

Deletes fields.

Syntax

php
public function delete(string ...$fields): int

Parameters

...$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

php
$cart->delete('promo');                 // 1
$cart->delete('promo');                 // 0 — it is already gone
$cart->delete('qty', 'sku', 'total');   // 3

A 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

php
public function ttl(string $field): ?int

Parameters

$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

php
$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 exist

persist()

Clears a field’s lifetime, leaving the field in place.

Syntax

php
public function persist(string $field): bool

Parameters

$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

php
$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 more

The 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

php
public function expireKey(int $seconds): bool

Parameters

$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

php
$cart->setAll(['qty' => '1']);
$cart->expireKey(86400);     // an abandoned cart lives for a day

keyTtl()

Reports how long the whole hash has left to live.

Syntax

php
public function keyTtl(): ?int

Parameters

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

php
public function deleteKey(): bool

Parameters

None.

Returns

true if the key existed.

Example

php
$cart->deleteKey();    // true
$cart->deleteKey();    // false — already gone

name()

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

php
public function name(): string

Parameters

None.

Returns

The full key name.

Example

php
$cursor = null;
$store->raw()->hScan($cart->name(), $cursor, '*', 100);   // walking a large hash

Server 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:

text
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