redis-py pipeline type hints: a 17-month-old open issue and a direct conversation with the core team
033 ended with the EKS config validated and the Helm chart linted, waiting on AWS credentials. Today was different: no NexusFlow, no Kubernetes. I opened a real open-source issue on redis/redis-py, diagnosed why method chaining on Pipeline fails mypy, hit the same architectural wall the maintainer hit when she researched it a year ago, and started a direct conversation with the Redis core team on GitHub. The PR is not open yet. The maintainer is reviewing the trade-off. The branch exists. The code passes mypy, ruff, and vulture.
Transmission 033 ended with the EKS config ready and no AWS credentials to apply it.
Today I stepped outside NexusFlow entirely.
The constraint
I was looking at open issues in redis/redis-py — the official Python client for Redis, downloaded roughly 60 million times a month. Issue #3615, opened April 2025, reported that method chaining on Pipeline fails the type checker:
pipe = r.pipeline()
pipe.set("a", "a value").set("b", "b value").get("a").execute()
mypy complains on the first .set(). The return type on Pipeline.set is inherited directly from CoreCommands, where it is annotated as bool. The type checker sees bool.set(...) and stops.
The issue had been open for 17 months. No pull request. No assignee.
The diagnostic path
Why the type annotation breaks
Pipeline inherits from Redis, which inherits from CoreCommands. Every command — set, get, incr, hset — is defined in CoreCommands with a concrete return type matching its actual Redis response. For set, that is bool. For get, it is str | None.
At runtime, Pipeline overrides those commands to buffer the call instead of executing it immediately. It returns self. But the type annotations still say bool.
There are two obvious fixes and both are wrong:
Option A — Per-command overloads: Add a Pipeline-specific @overload to every command so the type checker knows to return Self when called on a Pipeline. This doubles the overload count across CoreCommands and all the module command packages (json, timeseries, search). Hundreds of signatures to write. Hundreds to maintain every time a new Redis command is added. The maintainer already researched and rejected this.
Option B — Generic CoreCommands: Make CoreCommands generic over a return wrapper:
_CmdReturnT = TypeVar("_CmdReturnT")
class CoreCommands(Generic[_CmdReturnT]):
def set(self, name: KeyT, value: EncodableT, ...) -> _CmdReturnT:
...
Redis binds CommandReturnT to the real response type. Pipeline binds it to Self. No overload duplication.
I didn’t pick option B immediately. Python typing does not support higher-kinded types. Parameterizing CoreCommands across hundreds of methods in five module packages has a blast radius wide enough to break existing users. I left that as the theoretical answer and kept looking.
What can actually ship
The two internal dispatching methods on Pipeline were unannotated:
execute_command— typed asAnyinredis/client.py,Union["Pipeline", Awaitable["Pipeline"]]inredis/asyncio/client.py.pipeline_execute_command— typed as-> "Pipeline"in sync, unannotated in async.
Neither preserves the subclass type. If you subclass Pipeline, the type checker loses track of your subclass the moment you call either method.
The fix: add _PipelineT = TypeVar("_PipelineT", bound="Pipeline") to both files and retype the dispatchers.
redis/client.py (sync):
_PipelineT = TypeVar("_PipelineT", bound="Pipeline")
class Pipeline(Redis):
def execute_command(self: _PipelineT, *args, **kwargs) -> _PipelineT: ...
def pipeline_execute_command(self: _PipelineT, *args, **options) -> _PipelineT: ...
redis/asyncio/client.py (async):
_PipelineT = TypeVar("_PipelineT", bound="Pipeline")
class Pipeline(Redis):
def execute_command(
self: _PipelineT, *args, **kwargs
) -> Union[_PipelineT, Awaitable[_PipelineT]]: ...
def pipeline_execute_command(self: _PipelineT, *args, **options) -> _PipelineT: ...
The proof
Verification
mypy: no new errors introduced
ruff check: passed
ruff format --check: passed
vulture redis whitelist.py --min-confidence 80: (empty — _PipelineT not flagged)
On branch fix/pipeline-type-hints
Changes not staged for commit:
modified: redis/asyncio/client.py
modified: redis/client.py
Zero runtime impact. Annotations only. Two files changed. No CoreCommands touched.
What this does not fix
pipe.set("a", "val").set("b", "val2").execute() still fails mypy on the first .set(). The dispatchers are now correctly typed, but set itself still returns bool via inheritance. Fully fixing that requires option A or option B above. Neither is viable without maintainer input on direction.
The conversation
I asked first.
Me:
Before I open a PR, how would you prefer to handle this?
1. Adding pipeline-specific overloads to the commands?
2. Refactoring CoreCommands using protocols to avoid duplicate overloads?
@petyaslavova (Redis core team, Collaborator):
Adding pipeline specific overloads is not an option — this would mean
to double the overloads and it becomes too hard to manage those.
Can you elaborate more on the refactoring option? When I researched
this I couldn't find a reasonable solution to this problem, so I'm
really interested on what can be done to fix this...
She confirmed option A is off the table. She asked about option B. I walked through why the generic CoreCommands approach is theoretically correct but has a wide blast radius in Python’s current type system, and proposed shipping the dispatcher fix as a first PR — a clean, non-breaking improvement that the maintainer can evaluate without committing to a full architecture overhaul.
The maintainer is reviewing it.
The branch is ready. The code is verified. The maintainer is thinking.