Redis · Hashes

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

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

php
public function get(string $field): mixed

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

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

getMany()

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

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

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

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

all()

php
public function all(): array

The whole hash. No arguments.

Returns a “field → value” array; for a missing key, an empty array.

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 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()

php
public function fields(): array
public function values(): array

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

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

php
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()

php
public function count(): int

The number of fields. No arguments. Returns the count, 0 for a missing key. O(1).

php
if ($cart->count() === 0) {
  return null;    // there is no cart
}

Writing

set()

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

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

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

Rewriting 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()

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

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

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

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

Writing five fields with one setAll() is one round trip instead of five. On a hot path that shows.

increment() and decrement()

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

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

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

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

This is one server-side command, not “read, add, write”: two concurrent requests cannot both read 5 and both write 6.

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

Such a call used to return 0 — a refusal that looked like “the counter reset”. Now it is an exception.

delete()

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

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

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

A 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()

php
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().

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 there is no such field

persist()

php
public function persist(string $field): bool

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

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

The 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()

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

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

The lifetime counts from now: expireKey(time() + 86400) asks for fifty-six years and says nothing about it.

keyTtl()

php
public function keyTtl(): ?int

No 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()

php
public function deleteKey(): bool

Deletes the hash with all its fields. No arguments. Returns true when the key existed.

name()

php
public function name(): string

The key name with the prefix — what the server sees. Needed for commands the handle does not wrap:

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

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

php
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