A Redis hash is a field-addressable record under one key, not a schema-checked object or an automatically nested document. It is useful when callers need a few attributes without replacing the whole record. That advantage depends on access patterns, field sizes, and the meaning of replies, not merely on choosing the hash type.

Read the fields your caller actually needs

HGET retrieves one field. HMGET retrieves selected fields in the requested order, while HGETALL returns the entire mapping. With redis-py’s binary responses, an absent field becomes None and an existing empty value becomes empty bytes. A truthiness check incorrectly treats those two states as equivalent. An absent key also produces missing values through HMGET.

Field names and values are bytes; a name containing a dot is still a literal name. Redis does not interpret name.first as traversal through a nested object. Likewise, a string containing JSON remains a Redis string: HGET does not parse it. Decide serialization and validation in the application and preserve the distinction between an omitted attribute and an intentionally empty one.

Updates have precise return values

HSET changes the specified fields and leaves others untouched. A multi-field call is one command, but its integer reply counts newly added fields, not successful replacements. Updating an existing name can correctly return zero. Treating that zero as failure can cause needless retries or misleading error reports.

HINCRBY changes an integer field in one command and starts from zero when the field is absent. It rejects noninteger values and overflow beyond signed 64-bit range. This avoids a read-modify-write race for that increment; it does not make surrounding application checks atomic. Separate HGET, local arithmetic, and HSET can still overwrite another client’s update.

HDEL counts fields actually removed, and deleting the final field removes the key. Do not use an empty hash as a persistent placeholder for an entity. Whole-key expiration also removes its fields. Current Redis additionally supports field deadlines; the exercise below assigns no expirations and does not test lifetime behavior.

Compact representation is an implementation choice

Small hashes can use listpack encoding instead of a hash table. Redis 8.10.1’s standard thresholds are 512 entries and 64 bytes per field name or value; deployed settings can differ. Crossing an applicable limit changes the representation, not the public hash operations or the stored values.

Compact storage trades internal scanning work for reduced overhead and locality. Raising thresholds substantially requires measurement: a command’s advertised complexity is not a promise that arbitrarily large fields or changed configuration have no cost. Inspect OBJECT ENCODING when diagnosing representation, but do not make application correctness depend on that internal label or an assumed memory-saving ratio.

When it breaks

HGETALL grows with the whole hash and transfers both names and values. Fetching thousands of fields when the caller needs two wastes work, even if they share one key. HMGET’s work grows with the requested field count; it is not free either. Never use returned map iteration order as an application ordering contract.

WRONGTYPE indicates that the key holds another Redis type, not that a field is missing. Noninteger counters require an explicit data decision, not a retry loop. One huge hash also remains one key rather than automatically spreading its fields across cluster nodes. Choose record boundaries around actual access and ownership rather than packing unrelated data together.

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 Redis 8.10.1, redis-py 8.1.0, and Python 3.14.4. The setup builds only Redis core with libc and installs the client in a temporary virtual environment.

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. It starts a private Unix-socket server with TCP and persistence disabled. Only this server receives deliberately small thresholds: four entries and eight bytes. The test closes its client, terminates its own process, and removes the temporary runtime directory; it never connects to 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-hash-", 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,
            "--hash-max-listpack-entries", "4", "--hash-max-listpack-value", "8",
        ], 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": "Ada", "visits": "0", "empty": b""}) == 3
    assert client.hset("profile", "name", "Ada L") == 0
    assert client.hmget("profile", ["visits", "name", "missing", "empty"]) == [
        b"0", b"Ada L", None, b"",
    ]
    assert client.hmget("absent", ["a", "b"]) == [None, None]
    assert client.hgetall("profile") == {b"name": b"Ada L", b"visits": b"0", b"empty": b""}
    assert client.hincrby("profile", "visits", 1) == 1
    assert client.hget("profile", "name") == b"Ada L"
    print("HSET counts new fields; HMGET preserves order and distinguishes missing from empty")

    client.set("json", '{"name":"Ada"}')
    try:
        client.hget("json", "name")
    except redis.ResponseError as error:
        assert "WRONGTYPE" in str(error)
    else:
        raise AssertionError("a JSON string is not a hash")
    client.hset("literal", "name.first", b"Ada")
    assert client.hget("literal", "name") is None
    assert client.hget("literal", "name.first") == b"Ada"
    client.hset("invalid", "counter", "3.5")
    try:
        client.hincrby("invalid", "counter", 1)
    except redis.ResponseError as error:
        assert "integer" in str(error)
    else:
        raise AssertionError("an integer counter cannot contain 3.5")
    assert client.hget("invalid", "counter") == b"3.5"
    client.hset("overflow", "counter", 2**63 - 1)
    try:
        client.hincrby("overflow", "counter", 1)
    except redis.ResponseError as error:
        assert "overflow" in str(error)
    else:
        raise AssertionError("signed 64-bit integer overflow must fail")
    assert client.hget("overflow", "counter") == str(2**63 - 1).encode()
    print("wrong types and invalid or overflowing counters fail; dotted fields are literal")

    assert client.object("ENCODING", "profile") == b"listpack"
    assert client.hset("profile", "fourth", "ok") == 1
    assert client.object("ENCODING", "profile") == b"listpack"
    assert client.hset("profile", "fifth", "ok") == 1
    assert client.object("ENCODING", "profile") == b"hashtable"
    client.hset("value-limit", "field", b"abcdefgh")
    assert client.object("ENCODING", "value-limit") == b"listpack"
    client.hset("value-limit", "field", b"abcdefghi")
    assert client.object("ENCODING", "value-limit") == b"hashtable"
    assert client.hget("profile", "visits") == b"1"
    assert client.hget("value-limit", "field") == b"abcdefghi"
    print("private 4-field and 8-byte thresholds each trigger listpack to hashtable conversion")

    assert client.hdel("value-limit", "absent") == 0
    assert client.hdel("value-limit", "field") == 1
    assert client.exists("value-limit") == 0
    print("deleting the last field removes the hash key")

The assertions distinguish added fields from replacements, preserve HMGET ordering and missing-versus-empty values, and verify unchanged neighboring fields. A JSON string raises WRONGTYPE, a dotted field stays literal, and invalid or overflowing counters remain unchanged after rejection.

At four short fields the profile remains listpack; the fifth switches it to hashtable. A separate hash switches when its value grows from eight to nine bytes. Reads still return the expected values, and deleting the final field removes that key. These results demonstrate configured encoding transitions and command semantics, not production memory savings, latency, concurrent-load behavior, or cluster distribution.

Sources