A Redis hash maps field names to byte values under one key. It fits an entity whose attributes are read or changed independently, such as a profile with a name and score. HGET and HSET each operate in one command; using a hash does not inherently add a second network hop compared with a string.
Read and update the intended fields
HGET fetches one field, HMGET fetches selected fields in the requested order, and HGETALL returns all field-value pairs. Redis does not automatically interpret a value as a nested object, follow a dotted field path, or index arbitrary combinations of attributes. Field naming and serialization still belong to the application.
HSET can write several fields in one command, leaving unspecified fields alone. Its integer reply counts newly added fields, not every changed value: updating an existing field can succeed while returning zero. HDEL reports how many fields it actually removed; deleting the final field removes the hash key itself. An empty hash is not a durable placeholder.
Missing fields and empty values differ. With redis-py binary responses, HMGET returns None for a missing field and empty bytes for an existing empty field. Avoid truthiness checks that collapse these states. HGETALL is convenient for small objects, but its work and response grow with the entire hash. Prefer selected fields when the caller needs only a subset.
Expiration has two levels
A key-level expiration removes the entire hash. HSET preserves that key deadline, but Redis 7.4 and newer also support individual field expiration through HEXPIRE and HPEXPIRE. Plain HSET clears the expiration of a field it overwrites; HINCRBY preserves that field’s existing deadline. Field TTL -1 means no field deadline, not immunity from the parent key’s expiration.
For Redis 8.0 and newer, HSETEX can write field values and their expiration together. Its KEEPTTL option retains field deadlines during replacement. Separate HSET and HPEXPIRE calls have a failure window between updating the value and assigning its lifetime. Choose the operation that matches the intended lifetime, and verify the actual deployed Redis version before relying on field-expiration commands.
When it breaks
Partial updates prevent replacing unrelated attributes, but they do not make HGET, local arithmetic, and HSET one atomic operation. Two clients can read the same score, each add one, and both write the same result. HINCRBY performs an integer change in one command; invalid integer values are rejected. Multi-field business conditions still need an explicitly atomic protocol, not merely a hash-shaped data model.
A serialized JSON string remains a string, so HGET against it returns WRONGTYPE. Meanwhile, large fields or unbounded field counts can make the hash expensive to transfer and operate on. A single hash is still one Redis key, not an automatic partitioning scheme. Choose an opaque string when the application always exchanges one whole payload; choose a hash when independent field access provides real value.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The setup builds only Redis core with libc and installs the client in a temporary virtual environment, without optional modules or system-wide installation.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. Its private Redis uses a Unix socket beneath /tmp, with no TCP listener, scheduled snapshots, or append-only persistence. It closes its own client, terminates its process, and removes the temporary runtime directory. Dependency downloads need network access; the experiment cannot reach a default local Redis.
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
with instance() as client:
assert client.hset("profile", mapping={
"name": b"Ada", "score": b"0", "empty": b"",
}) == 3
assert client.hset("profile", "name", b"Ada L") == 0
assert client.hmget("profile", ["name", "empty", "missing"]) == [
b"Ada L", b"", None,
]
assert client.hlen("profile") == 3
print("HSET reports added fields, not updates; empty bytes differ from None")
assert client.pexpire("profile", 60000)
key_deadline = client.pexpiretime("profile")
assert client.hpexpire("profile", 30000, "name", "score") == [1, 1]
field_deadlines = client.hpexpiretime("profile", "name", "score")
assert all(value > 0 for value in field_deadlines)
assert client.hset("profile", "name", b"Grace") == 0
assert client.pexpiretime("profile") == key_deadline
assert client.hpttl("profile", "name") == [-1]
assert client.hincrby("profile", "score", 1) == 1
assert client.hpexpiretime("profile", "score") == [field_deadlines[1]]
print("HSET keeps key TTL but clears its field TTL; HINCRBY keeps field TTL")
assert client.hsetex("session", mapping={"token": b"A"}, px=60000) == 1
token_deadline = client.hpexpiretime("session", "token")
assert token_deadline[0] > 0
assert client.hsetex("session", mapping={"token": b"B"}, keepttl=True) == 1
assert client.hget("session", "token") == b"B"
assert client.hpexpiretime("session", "token") == token_deadline
print("HSETEX writes field value and TTL together; KEEPTTL preserves its deadline")
client.hset("race", "score", 0)
a = int(client.hget("race", "score"))
b = int(client.hget("race", "score"))
client.hset("race", "score", a + 1)
client.hset("race", "score", b + 1)
assert client.hget("race", "score") == b"1"
client.hset("race", "score", 0)
assert client.hincrby("race", "score", 1) == 1
assert client.hincrby("race", "score", 1) == 2
print("controlled stale-read schedule loses an update; two HINCRBY calls yield 2")
client.hset("bad", "score", b"3.5")
try:
client.hincrby("bad", "score", 1)
except redis.ResponseError:
pass
else:
raise AssertionError("HINCRBY requires an integer")
assert client.hget("bad", "score") == b"3.5"
client.set("blob", b'{"name": "Ada"}')
try:
client.hget("blob", "name")
except redis.ResponseError as error:
assert "WRONGTYPE" in str(error)
else:
raise AssertionError("a serialized string is not a Redis hash")
print("invalid counters and wrong key types are rejected")
client.hset("single", "field", b"value")
assert client.hdel("single", "missing") == 0
assert client.hdel("single", "field") == 1
assert client.exists("single") == 0
print("HDEL of the last field removes the key")
HSET first returns three, then zero for a successful replacement. HMGET preserves the distinction between empty and missing. Comparing absolute deadlines shows that a name update preserves the key deadline but removes that field’s deadline, while incrementing score retains its field deadline. HSETEX demonstrates atomic field lifetime assignment and deadline-preserving replacement.
The stale-read sequence deliberately interleaves two hypothetical clients’ operations; it is a deterministic lost-update reproduction, not a concurrent load test. Its final score is one, whereas two HINCRBY calls produce two. Invalid counters and wrong types are rejected, and deleting the last field removes its key. These assertions establish command behavior, not memory efficiency, throughput, cluster scaling, or retry safety.
Sources
- Redis hashes, field-oriented data modeling and command behavior, checked 2026-09-08.
- Redis HSET, partial updates and newly added field counts, checked 2026-09-08.
- Redis HEXPIRE, field deadlines and overwrite behavior, checked 2026-09-08.
- Redis HSETEX, atomic field value/expiration updates and KEEPTTL, checked 2026-09-08.
- Redis HDEL, removal counts and deletion of the final field, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.