Migrate to Unified Responses#

redis-py can return protocol-independent Python response shapes. When unified responses are enabled, redis-py applies the same public response structure for the affected commands whether the connection uses RESP2 or RESP3.

Unified responses are selected with legacy_responses=False. The protocol still controls the wire format; legacy_responses controls the Python shape returned by redis-py.

Unified responses are the recommended mode for new projects and for applications that can update their response handling. They provide the most portable application-facing API because command results keep the same Python shape when you change the Redis serialization protocol.

Enable Unified Responses#

Use legacy_responses=False when constructing a client.

import redis

# Default wire protocol, unified Python responses.
r = redis.Redis(legacy_responses=False)

# RESP2 wire, unified Python responses.
r_resp2 = redis.Redis(protocol=2, legacy_responses=False)

# RESP3 wire, unified Python responses.
r_resp3 = redis.Redis(protocol=3, legacy_responses=False)

The same option is available for asyncio and cluster clients.

import redis.asyncio as redis_async
from redis.cluster import RedisCluster

async_r = redis_async.Redis(legacy_responses=False)
cluster = RedisCluster(host="localhost", port=6379, legacy_responses=False)

Connection URLs can also select unified responses.

r = redis.from_url("redis://localhost:6379?legacy_responses=false")
r = redis.from_url(
    "redis://localhost:6379?protocol=2&legacy_responses=false"
)

Response Modes#

Client options

Wire protocol

Python response shape

Redis()

Default RESP3 wire

Legacy RESP2-compatible shape

Redis(protocol=2)

RESP2

Legacy RESP2 shape

Redis(protocol=3)

RESP3

Native RESP3 shape

Redis(legacy_responses=False)

Default RESP3 wire

Unified shape

Redis(protocol=2, legacy_responses=False)

RESP2

Unified shape

Redis(protocol=3, legacy_responses=False)

RESP3

Unified shape

decode_responses is independent from unified responses. It still controls bulk-string decoding where the command parser does not otherwise normalize a structural key or value.

Migration Checklist#

  1. Find the redis-py client construction points in your application, including background workers, admin scripts, asyncio clients, and cluster clients.

  2. Enable legacy_responses=False for one environment or service path first. The server protocol can stay unchanged unless you also want to pin protocol=2 or protocol=3.

  3. Check the Redis commands and execute_command() calls your application depends on against the tables below. Update application API type hints, response models, serializers, and assertions to reflect the unified response shapes.

  4. Review module command responses such as JSON, TimeSeries, RediSearch, and probabilistic commands separately; several of them return richer objects or nested containers in unified mode.

  5. Keep decode_responses decisions separate. Unified responses normalize response structures, while decode_responses still controls how bulk string data is decoded.

  6. Roll the change out gradually and monitor application paths that parse Redis responses manually.

RESP2 Legacy to Unified#

The following tables summarize the main differences when moving from protocol=2 with legacy responses to unified responses.

Core Commands#

Command

Change

RESP2 legacy example

Unified example

ZDIFF with scores

Flat or tuple score pairs normalize to list pairs with float scores.

[b"a", b"1"] or [(b"a", b"1")]

[[b"a", 1.0]]

ZINTER, ZRANGE, ZRANGEBYSCORE, ZREVRANGE, ZREVRANGEBYSCORE, ZUNION with scores

Tuple pairs become list pairs; scores are floats.

[(b"a", b"1")]

[[b"a", 1.0]]

ZPOPMAX, ZPOPMIN

Tuple pairs become list pairs; scores are floats.

[(b"a", 3)]

[[b"a", 3.0]]

BZPOPMAX, BZPOPMIN

Tuple result becomes list result; score is a float.

(b"key", b"a", 3)

[b"key", b"a", 3.0]

ZRANK, ZREVRANK with score

Rank response score is a float.

[2, b"3"]

[2, 3.0]

ZSCAN

Score pairs are lists; score_cast_func receives a float.

(0, [(b"a", b"1")])

(0, [[b"a", 1.0]])

ZRANDMEMBER with scores

Flat interleaved values become nested score pairs.

[b"a", b"1", b"b", b"2"]

[[b"a", 1.0], [b"b", 2.0]]

HRANDFIELD with values

Flat interleaved values become nested field/value pairs.

[b"f1", b"v1", b"f2", b"v2"]

[[b"f1", b"v1"], [b"f2", b"v2"]]

BLPOP, BRPOP

Tuple result becomes list result.

(b"key", b"value")

[b"key", b"value"]

ZMPOP, BZMPOP

Score values are floats inside nested list pairs.

[b"key", [(b"a", b"1")]]

[b"key", [[b"a", 1.0]]]

XREAD, XREADGROUP

List of stream entries becomes a dict keyed by stream.

[[b"s", [(b"1-0", {})]]]

{b"s": [(b"1-0", {})]}

LCS with IDX

Flat key/value list becomes a dict with string keys.

[b"matches", [...], b"len", 3]

{"matches": [...], "len": 3}

STRALGO ... IDX

Match ranges use lists and string keys.

{"matches": [((0, 2), (0, 2))], "len": 3}

{"matches": [[[0, 2], [0, 2]]], "len": 3}

CLIENT TRACKINGINFO

Flat list becomes dict; structural strings are decoded.

[b"flags", [b"on"], b"redirect", -1]

{"flags": ["on"], "redirect": -1}

COMMAND

Flags and ACL categories are sets of strings.

{"get": {"flags": [b"readonly"]}}

{"get": {"flags": {"readonly"}, "acl_categories": {...}}}

ACL GETUSER

Selector lists become selector dicts.

{"selectors": [[b"~*", b"+get"]]}

{"selectors": [{"keys": "~*", "commands": "+get"}]}

ACL LOG

age-seconds is float and client-info is parsed.

{"age-seconds": b"0.5"}

{"age-seconds": 0.5, "client-info": {...}}

SENTINEL state commands

flags becomes a set and derived booleans are present.

{"flags": "master,odown"}

{"flags": {"master", "odown"}, "is_master": True}

CLUSTER LINKS

Link dictionaries use string keys.

[{b"direction": b"to"}]

[{"direction": b"to"}]

CLUSTER SHARDS

Shard and node dictionaries use string keys.

{b"nodes": [{b"id": b"abc"}]}

{"nodes": [{"id": b"abc"}]}

GEOPOS

Coordinates are lists.

[(1.0, 2.0), None]

[[1.0, 2.0], None]

GEOSEARCH, GEORADIUS, GEORADIUSBYMEMBER with coordinates

RESP2 and RESP3 unified responses use tuple coordinates.

[b"place", (1.0, 2.0)]

[b"place", (1.0, 2.0)]

FUNCTION LIST

Flat function data becomes nested dictionaries.

[[b"library_name", b"lib", ...]]

[{b"library_name": b"lib", b"functions": [...]}]

MEMORY STATS

Structural keys are decoded and numeric values use native types.

{b"peak.allocated": b"1024"}

{"peak.allocated": 1024}

JSON#

Command

Change

RESP2 legacy example

Unified example

JSON.NUMINCRBY, JSON.NUMMULTBY

Legacy scalar paths are normalized with JSONPath array behavior.

5

[5]

JSON.RESP

Numeric string leaves that represent floats become Python floats.

[b"{", b"price", b"-19.5"]

[b"{", b"price", -19.5]

JSON.OBJKEYS

Key values respect decode_responses instead of being forced to str.

["a", "b"]

[b"a", b"b"] when decode_responses=False

TimeSeries#

Command

Change

RESP2 legacy example

Unified example

TS.GET

Tuple becomes list.

(1, 2.0)

[1, 2.0]

TS.RANGE, TS.REVRANGE

Sample tuples become sample lists.

[(1, 2.0)]

[[1, 2.0]]

TS.MGET

Sorted list of dicts becomes a key/value dict.

[{b"k": [{}, None, None]}]

{b"k": [{}, [1, 2.0]]}

TS.MRANGE, TS.MREVRANGE

Sorted list of dicts becomes a dict with metadata slot.

[{b"k": [labels, samples]}]

{b"k": [labels, metadata, samples]}

TS.QUERYINDEX

Key strings are preserved instead of coerced to numbers.

2

"2"

RediSearch Commands#

Command

Change

RESP2 legacy example

Unified example

FT.INFO

Attribute sublists become structured dictionaries.

[["identifier", "title", "SORTABLE"]]

[{"identifier": "title", "flags": ["SORTABLE"]}]

FT.CONFIG GET

Keys and values are strings.

{b"TIMEOUT": b"500"}

{"TIMEOUT": "500"}

FT.SEARCH

Result includes response warnings.

result.total, result.docs

result.total, result.docs, result.warnings

FT.AGGREGATE

AggregateResult includes total and warnings.

result.rows

result.total, result.rows, result.warnings

FT.PROFILE

Returns parsed result plus ProfileInformation.

(result, ProfileInformation(list_data))

(result, ProfileInformation(profile_data))

FT.HYBRID

Returns HybridResult; result field values and warnings stay bytes by default.

HybridResult(..., results=[{"field": b"value"}])

HybridResult(..., results=[{"field": b"value"}])

Probabilistic#

Command

Change

RESP2 legacy example

Unified example

TOPK.ADD, TOPK.INCRBY, TOPK.LIST

Item names are preserved instead of coerced through numeric parsing.

"42" could become 42

"42" stays "42"

RESP3 Legacy to Unified#

The following tables summarize the main differences when moving from protocol=3 with legacy responses to unified responses.

Core Commands#

Command

Change

RESP3 legacy example

Unified example

Sorted-set score commands

Scores pass through the same callback normalization as RESP2 unified.

[[b"a", 1.0]]

[[b"a", 1.0]]

ZSCAN

Score pairs are lists and score_cast_func receives floats.

(0, [[b"a", 1.0]])

(0, [[b"a", 1.0]])

ACL CAT, ACL HELP, ACL LIST, ACL USERS

Byte strings are decoded to strings.

[b"default"]

["default"]

ACL GENPASS, ACL WHOAMI, CLIENT GETNAME, RESET

Byte scalar becomes string scalar.

b"default"

"default"

ACL LOG

age-seconds is float, client-info is parsed, and structural strings are decoded.

{"age-seconds": "0.5"}

{"age-seconds": 0.5, "client-info": {...}}

CLIENT TRACKINGINFO

Structural keys and string lists are decoded.

{b"flags": [b"on"]}

{"flags": ["on"]}

COMMAND

Flags and ACL categories are sets of strings.

{"get": {"flags": [b"readonly"]}}

{"get": {"flags": {"readonly"}, "acl_categories": {...}}}

CLUSTER LINKS, CLUSTER SHARDS

Dict keys are strings at the exposed structural levels.

[{b"direction": b"to"}]

[{"direction": b"to"}]

GEOHASH

Hash strings are decoded.

[b"sqc8b49rny0"]

["sqc8b49rny0"]

GEOPOS

Keeps RESP3-style list coordinates.

[[1.0, 2.0]]

[[1.0, 2.0]]

GEOSEARCH, GEORADIUS, GEORADIUSBYMEMBER with coordinates

Coordinates normalize to tuples.

[b"place", [1.0, 2.0]]

[b"place", (1.0, 2.0)]

LCS and STRALGO ... IDX

Dict keys are strings and match ranges use the unified list shape.

{b"matches": [...]}

{"matches": [...], "len": 3}

SENTINEL state commands

Native RESP3 state maps normalize to the unified state dictionaries.

{b"flags": b"master"}

{"flags": {"master"}, "is_master": True}

Module Commands#

Command

Change

RESP3 legacy example

Unified example

BF.INFO, CF.INFO, CMS.INFO, TOPK.INFO, TDIGEST.INFO

Raw module info maps become rich info objects.

{b"Capacity": 100}

info.capacity == 100

TDIGEST.BYRANK, TDIGEST.BYREVRANK, TDIGEST.CDF, TDIGEST.QUANTILE

Raw lists are parsed with numeric and special-value handling.

[0.5, b"inf"]

[0.5, "inf"]

TS.INFO

Raw TimeSeries info map becomes TSInfo.

{b"totalSamples": 10}

info.total_samples == 10

TS.MRANGE, TS.MREVRANGE

Keeps the metadata slot in the unified three-element value.

{b"k": [labels, samples]}

{b"k": [labels, metadata, samples]}

JSON.TYPE for missing keys

Wrapped missing value becomes bare None.

[None]

None

JSON.RESP

Numeric float leaves are normalized consistently.

[b"{", b"price", b"-19.5"]

[b"{", b"price", -19.5]

FT.SEARCH

Raw RESP3 result map becomes Result.

{"total_results": 2, "results": [...]}

result.total == 2

FT.AGGREGATE

Raw RESP3 aggregate map becomes AggregateResult.

{"total_results": 2, "results": [...]}

result.total == 2

FT.PROFILE

Single ProfileInformation wrapper becomes parsed result plus profile info.

ProfileInformation(raw_profile_response)

(result, ProfileInformation(profile_data))

FT.SPELLCHECK

Native nested RESP3 spellcheck map becomes normalized term suggestions.

{"results": {"term": [{"fix": 0.0}]}}

{"term": [{"score": "0", "suggestion": "fix"}]}

FT.INFO, FT.CONFIG GET, FT.SYNDUMP

Structural keys are strings.

{b"index_name": b"idx"}

{"index_name": "idx"}

FT.HYBRID

Raw native response becomes HybridResult; field values remain bytes by default.

{"total_results": 1, "results": [...]}

HybridResult(total_results=1, results=[...])

HYBRID Command Loaded Fields#

FT.HYBRID is experimental. Unified responses for the HYBRID command preserve loaded field values as bytes by default to keep binary data, such as vector fields, intact.

Use decode_field=True only for fields that are known text values.

post = HybridPostProcessingConfig()
post.load("@title", decode_field=True)
post.load("@embedding", decode_field=False)