Skip to content

Failure Modes

Things that look like bugs but are intentional. Read this before "fixing" any of them.

Result row keys are bytes

Executor.execute(...).rows returns dicts whose keys are bytes, not str, when the underlying Redis client uses default decode_responses=False. This is consistent with raw redis-py behavior. The fix at the call site is to construct Redis(decode_responses=True). Do not "fix" this in executor.py by force-decoding; that breaks users who deliberately want bytes (binary fields, vectors). See Result shape for the full story.

Stopwords are silently stripped

WHERE title = 'bank of america' becomes @title:"bank america" because RediSearch does not index default stopwords. A UserWarning is emitted, but the query proceeds. To preserve stopwords the user must create the index with STOPWORDS 0. This is intentional: failing the query would be worse than warning and proceeding.

= on TEXT fields is exact phrase, not tokenized AND

title = 'gaming laptop' translates to @title:"gaming laptop" (phrase match), not @title:(gaming laptop) (tokenized AND). For tokenized search the user must call fulltext(title, 'gaming laptop'). The two are semantically different; do not collapse them.

OR inside fulltext() is case-sensitive

fulltext(title, 'a OR b') triggers a union (@title:(a|b)). fulltext(title, 'a or b') is treated as a literal three-word AND search (@title:(a or b)). This matches RediSearch's own grammar and is documented; do not silently normalize the case.

IS NULL requires Redis 7.4+ AND INDEXMISSING

WHERE email IS NULL translates to ismissing(@email). If the field was not declared with INDEXMISSING at index creation time, Redis returns a syntax error. The executor catches this case and rewraps the redis.ResponseError with a hint about Redis 7.4 + INDEXMISSING. If the catch is breaking, check that the wrap heuristic still matches new RediSearch error messages.

Translator.translate(sql) re-parses even if you already parsed

AsyncExecutor deliberately calls parse(sql) first to extract the index name, then calls translate_parsed(parsed) to avoid double-parsing. If you add a new code path, prefer translate_parsed when you already have a ParsedQuery. Calling translate(sql) from a code path that already parsed is wasteful but not incorrect.

AsyncSchemaRegistry.invalidate() cancels in-flight loads

Cancelling the in-flight FT.INFO is intentional: it prevents a post-invalidate stale write into the cache. The shielded await in ensure_schema() returns the current cache state when the underlying task is cancelled, so other awaiters do not propagate CancelledError. If you "simplify" by removing the shield, you reintroduce a race. See Async invariants.

Lazy schema-load failures are deferred

The default schema_cache_strategy="lazy" means a missing index does not fail at create_executor() time. It fails at the first execute() call that touches the index. This is intentional. If you need fail-fast at startup, pass schema_cache_strategy="load_all". Do not change the default.

score() plus aggregation raises ValueError

This is not a bug. score() requires WITHSCORES, which is FT.SEARCH only. Anything that forces FT.AGGREGATE (aggregations, GROUP BY, computed fields, date functions, geo > / >= / BETWEEN, HAVING) cannot coexist with score(). The translator surfaces the conflict explicitly rather than silently dropping one side. See FT.SEARCH vs FT.AGGREGATE.

OR plus geo > / >= / BETWEEN raises ValueError

Same family as above. Greater-than-distance is implemented as a top-level FILTER clause, which is ANDed with the rest of the query. Combining with SQL-level OR would silently change semantics, so it is rejected. Same for date-function predicates combined with OR.

Coverage gate failures are not flakes

If CI reports coverage below 100%, do not retry. The failure is real. Either add a test or delete the unreachable branch. The project explicitly forbids # pragma: no cover (see Testing philosophy).