Result Shape¶
Every Executor.execute() call returns a QueryResult with two attributes: rows (a list of dicts) and count (an integer). Both look simple but have a few corners that are worth understanding once so you do not re-derive them from test failures.
What count means¶
The integer in count is what Redis returned at position 0 of the reply. Its meaning depends on which command ran (see FT.SEARCH vs FT.AGGREGATE):
FT.SEARCH(no aggregation):countis the total number of matching documents in the index, regardless ofLIMIT. Socountcan be much larger thanlen(rows). This is useful for pagination.FT.AGGREGATE(any aggregation, GROUP BY, computed field, date function):countis the number of rows in the reply, which is what you got back. AfterLIMIT, the two are equal.
What rows looks like¶
A row is always a dict, but the keys and values depend on three orthogonal factors.
Factor 1: client decoding¶
A Redis() client without decode_responses=True returns bytes. A row therefore looks like:
Redis(decode_responses=True) returns strings:
The library does not normalise this. If you want strings, configure the client. If you want native types (e.g., 1499 as int), parse them yourself.
Factor 2: which command ran¶
FT.SEARCH rows contain only the document fields you asked for in SELECT. FT.AGGREGATE rows can include computed columns, group keys, and reduced values; original document fields appear only if you asked for them.
# FT.SEARCH: SELECT title, price FROM products WHERE ...
{b"title": b"gaming laptop", b"price": b"1499"}
# FT.AGGREGATE: SELECT category, COUNT(*) AS n FROM products GROUP BY category
{b"category": b"electronics", b"n": b"2"}
# FT.AGGREGATE: SELECT name, geo_distance(loc, POINT(...)) AS d FROM stores
{b"name": b"Downtown", b"d": b"4823.5"}
Factor 3: scoring¶
If your SELECT contains score(), the underlying FT.SEARCH runs with WITHSCORES and an extra column appears in every row:
# SELECT title, score() AS relevance FROM products WHERE fulltext(...)
{b"title": b"gaming laptop", b"relevance": "0.5"}
The score column name is whatever alias you wrote (score() AS relevance produces relevance). If you used score() without AS, the library falls back to a stable internal name.
The score's value type is str (or bytes depending on decoding); if you need a float, convert at the call site.
Factor 4: RETURN 0¶
If your SELECT is just score() with no document fields, the underlying command uses RETURN 0 (no document fields returned). Each row contains only the score column:
This is mostly useful when you want a relevance-only ranking without paying to ship the document body back.
Score-column collision avoidance¶
What happens if your document has a field literally named __score? The library detects the collision and renames the score column. The deterministic fallback is __score_<alias>. So:
score() AS __scoreagainst documents with no__scorefield: row key is__score.score() AS __scoreagainst documents with a field named__score: row key is__score___score.
You will not normally see this; it is a defensive measure for the unusual case.
Why the library does not "fix" this¶
It would be tempting to normalise everything: always strings, always float scores, always uniform shape. The library does not, for three reasons.
- Bytes is the right default for Redis. Some fields legitimately contain binary data. Force-decoding to UTF-8 would corrupt those.
- The shape difference between
FT.SEARCHandFT.AGGREGATEis intrinsic to the Redis commands. Hiding it would lie about what is happening. - One-call-site logic is cheap; library-wide policy is expensive. A user who wants a uniform shape can write a 5-line decoder once, customised to their data. The library shipping that decoder for everyone is the wrong place for the policy.
When in doubt¶
The repr tells you the keys, values, and types in front of you. Build your downstream code against what you actually see.