← Files RedisARCHIVED FILE
skills/redis-search/references/aggregate-pipeline.md
15 KB · Oct 2, 2026 · 00:19 UTC
# Build FT.AGGREGATE Pipelines in the Correct Stage Order
`FT.AGGREGATE` runs stages in the order you write them, like a Unix pipeline. The canonical order is `LOAD → APPLY → FILTER → GROUPBY/REDUCE → APPLY → SORTBY → LIMIT`. Swapping stages doesn't error — it silently changes what your query computes. For paginating large aggregates, see [aggregate-cursors.md](aggregate-cursors.md).
**Correct:** Canonical pipeline against the Bicycle dataset — load needed fields, project a derived field, filter, group, sort, limit.
```
# Average price per brand for mountain bicycles, top 5 brands
FT.AGGREGATE idx:bicycle "@type:{mountain}"
LOAD 3 @brand @price @condition
APPLY "@price * 0.9" AS sale_price
FILTER "@condition == 'new'"
GROUPBY 1 @brand
REDUCE COUNT 0 AS bike_count
REDUCE AVG 1 @price AS avg_price
REDUCE AVG 1 @sale_price AS avg_sale_price
SORTBY 2 @avg_price DESC
LIMIT 0 5
DIALECT 2
```
## Stages, in order
| Stage | Purpose | Notes |
|-------|---------|-------|
| `LOAD n @f1 @f2 ...` | Hydrate fields from the source doc into the pipeline. | Only loaded fields are visible to later stages. `LOAD *` pulls everything (expensive). |
| `APPLY <expr> AS alias` | Project a computed field. | Operates row-by-row before grouping. |
| `FILTER <expr>` | Drop rows that fail a predicate. | Filters *pipeline rows*, not the underlying index. Index-level filters belong in the query string. |
| `GROUPBY n @f1 ... REDUCE <fn> ...` | Collapse rows that share group keys. | Reducers: `COUNT`, `COUNT_DISTINCT`, `SUM`, `AVG`, `MIN`, `MAX`, `STDDEV`, `QUANTILE`, `TOLIST`, `FIRST_VALUE`, `RANDOM_SAMPLE`. |
| `APPLY` (post-group) | Compute derived fields over reducer output. | E.g. `APPLY "@bike_count / @brand_count" AS share`. |
| `SORTBY n @f1 ASC ...` | Order the result. | The `n` is the count of (field, direction) tokens. |
| `LIMIT offset num` | Slice the result. | For result sets > 1000 rows, use `WITHCURSOR` (see [aggregate-cursors.md](aggregate-cursors.md)). |
> **Common errors per stage** — see "Counting tokens, not fields" and "FILTER and LOAD discipline" below for nargs miscount on `LOAD`/`GROUPBY`/`SORTBY`/`REDUCE COUNT`, missing `@` on pipeline field references, missing `ASC`/`DESC` on `SORTBY`, and FILTER-before-LOAD errors.
## Common reducers — quick reference
```
REDUCE COUNT 0 AS n # count rows in group
REDUCE COUNT_DISTINCT 1 @user_id AS uniq # distinct values of @user_id
REDUCE SUM 1 @price AS total
REDUCE AVG 1 @price AS mean
REDUCE MIN 1 @price AS lo
REDUCE MAX 1 @price AS hi
REDUCE QUANTILE 2 @price 0.95 AS p95
REDUCE TOLIST 1 @model AS models # collect into a list
REDUCE FIRST_VALUE 1 @model BY @price DESC AS top_model
```
## Counting tokens, not fields
The most frequent class of `FT.AGGREGATE` parse errors is treating `<nargs>` as "number of semantic fields" when Redis counts *tokens that follow*. Same root cause, four shapes.
**Every pipeline field reference starts with `@`.** The `@` is part of the field token in `LOAD`, `GROUPBY`, `SORTBY`, `APPLY`, and `FILTER` — not just in the query string. Inside expressions like `@field >= 5` or `substr(@date, 0, 4)`, the `@` is still required.
```
# Bad: missing @ on pipeline field references
GROUPBY 1 category # "Unknown property 'category'. Did you mean '@category'?"
APPLY substr(date, 0, 4) AS year # "Unknown symbol 'date'"
FILTER "date >= '2022-01'" # "Unknown symbol 'date'"
# Good
GROUPBY 1 @category
APPLY substr(@date, 0, 4) AS year
FILTER "@date >= '2022-01'"
```
**`REDUCE` always follows a `GROUPBY`. For a whole-result aggregate, use `GROUPBY 0`.**
```
# Bad: REDUCE without a preceding GROUPBY — "Unknown argument 'REDUCE' at position 1"
FT.AGGREGATE idx:bicycle "@type:{mountain}" REDUCE AVG 1 @price AS avg_price DIALECT 2
# Good: single-row aggregate over all matched docs
FT.AGGREGATE idx:bicycle "@type:{mountain}"
GROUPBY 0
REDUCE AVG 1 @price AS avg_price
DIALECT 2
```
**`REDUCE COUNT 0` — the `0` is mandatory even though `COUNT` takes no args.** `<nargs>` is the count of arguments to the reducer, regardless of whether the reducer "really" needs any.
```
# Bad: "Bad arguments for COUNT: could not convert ..."
GROUPBY 0 REDUCE COUNT AS count
# Good
GROUPBY 0 REDUCE COUNT 0 AS count
```
**`LOAD <n>` also counts tokens, not fields. `path AS alias` = 3 tokens.** Same counting rule as `RETURN`. An aliased JSONPath (`$.path AS alias`) needs nargs=3; an unaliased load needs nargs=1.
```
# Bad: nargs=1 but the LOAD has 3 tokens — "Unknown argument 'AS' at position 4"
LOAD 1 $.beers[?(@.abv >= 0.07)] AS match
LOAD 1 $.brewery_id AS brewery_id
# Good: 1 aliased path = 3 tokens
LOAD 3 $.beers[?(@.abv >= 0.07)] AS match
# Good: 2 unaliased + 1 aliased = 2 + 3 = 5 tokens
LOAD 5 @date @subject $.event.ts AS ts
```
**`SORTBY <n>` counts tokens, not fields. Each sort entry is `@field ASC|DESC` = 2 tokens. Always supply `ASC` or `DESC`.**
```
# Bad: SORTBY 1 @count DESC — Redis consumes 1 token, then "Unknown argument 'DESC'"
SORTBY 1 @count DESC
# Bad: SORTBY 2 @brand DESC @price ASC — said 2, gave 4, trailing tokens become unknown args
SORTBY 2 @brand DESC @price ASC
# Good: one sort entry = 2 tokens
SORTBY 2 @count DESC
# Good: two sort entries = 4 tokens
SORTBY 4 @count DESC @brand ASC
```
A single pipeline can contain at most **one** `SORTBY` step ("Multiple SORTBY steps are not allowed"). For a secondary sort, extend the same token list rather than writing two `SORTBY` clauses.
**For top-N, prefer `SORTBY … MAX N` over a trailing `LIMIT 0 N`.** `MAX` lives inside `SORTBY`, is more efficient, and does not compose with `LIMIT` — pick one.
```
# Good (preferred): in-sort limit
SORTBY 2 @count DESC MAX 5
# Equivalent but less efficient
SORTBY 2 @count DESC
LIMIT 0 5
# Bad: combining MAX and LIMIT — redundant; pick one
SORTBY 2 @count DESC MAX 5 LIMIT 0 5
```
## FILTER and LOAD discipline
Pipeline `FILTER` operates on *pipeline rows*, which only contain attributes that were declared `SORTABLE` in the schema (auto-projected) or explicitly `LOAD`ed. Plain TEXT/TAG fields are not auto-projected.
**Prefer query-string filters over pipeline `FILTER` whenever possible.** The query string runs against the index; pipeline `FILTER` runs after candidates are materialized.
```
# Bad: FILTER on a TEXT field that wasn't loaded — "Unknown symbol 'date'"
FT.AGGREGATE idx:observations "@code:\"heart rate\""
GROUPBY 0 REDUCE COUNT 0 AS count
FILTER "@date >= '2023-01-01'"
DIALECT 2
# Good: express the date filter in the query string (it hits the index directly)
FT.AGGREGATE idx:observations "@date:2023* @code:\"heart rate\""
GROUPBY 0 REDUCE COUNT 0 AS count
DIALECT 2
# Also valid: LOAD the field first, then FILTER on it in the pipeline
FT.AGGREGATE idx:observations "@code:\"heart rate\""
LOAD 1 @date
FILTER "@date >= '2023-01-01'"
GROUPBY 0 REDUCE COUNT 0 AS count
DIALECT 2
```
**Downstream stages must reference the exact alias that was `LOAD`ed.** Loading a JSONPath without `AS` projects it under the path string, not its basename.
```
# Bad: loaded $.subject, then referenced @timestamp
# → "Property '@timestamp' not loaded nor in schema"
LOAD 1 $.subject
SORTBY 2 @timestamp DESC
# Good: load every alias you'll reference; rename with AS when needed
LOAD 2 @date @subject
SORTBY 2 @date DESC
# Good: explicit AS for a nested JSONPath
LOAD 2 $.subject AS subject $.event.ts AS ts
SORTBY 2 @ts DESC
```
**Nested JSON array predicates.** For "how many parents have at least one child matching X," use a JSONPath predicate + `exists`:
```
# How many breweries have at least one beer with ABV >= 0.07?
FT.AGGREGATE idx:breweries "*"
LOAD 1 $.beers[?(@.abv >= 0.07)] AS match
FILTER "exists(@match)"
GROUPBY 0 REDUCE COUNT 0 AS qualifying_breweries
DIALECT 2
```
## Reducer by intent
Match the reducer to the *shape* of the question.
```
# "how many" → GROUPBY 0 REDUCE COUNT 0 AS n
# "how many distinct X" → GROUPBY 1 @x GROUPBY 0 REDUCE COUNT 0 AS n
# (or single-step: REDUCE COUNT_DISTINCT 1 @x AS n)
# "list distinct X" → GROUPBY 0 REDUCE TOLIST 1 @x AS list
# "sum / total" → REDUCE SUM 1 @field AS total
# "average per Y" → GROUPBY 1 @y REDUCE AVG 1 @field AS avg
# "top row by stat" → REDUCE FIRST_VALUE 4 @other_field BY @stat DESC AS top
# "p95 / quantile" → REDUCE QUANTILE 2 @field 0.95 AS p95
# "min / max within group" → REDUCE MIN 1 @field / REDUCE MAX 1 @field
```
## Multi-step pipeline patterns
These compose multiple `GROUPBY` + `REDUCE` steps. The pipeline is sequential: each stage's output becomes the next stage's input, so the order of stages matters and the field names must thread through.
**Top-N per group (per-group count, then global sort + cap).** "Top N most frequent X per Y" — group by both first, then re-group by Y and pick the top X.
```
# Q: "Top brand per state by bike count"
FT.AGGREGATE idx:bicycle "*"
GROUPBY 2 @state @brand
REDUCE COUNT 0 AS cnt
GROUPBY 1 @state
REDUCE FIRST_VALUE 4 @brand BY @cnt DESC AS top_brand
REDUCE MAX 1 @cnt AS top_cnt
SORTBY 2 @top_cnt DESC
DIALECT 2
```
**Distinct values with their counts (single group + count).** Two-column return: the value and how many docs have it.
```
# Q: "How many bikes per category"
FT.AGGREGATE idx:bicycle "*"
GROUPBY 1 @category
REDUCE COUNT 0 AS n
SORTBY 2 @n DESC
DIALECT 2
```
**Count distinct (two-stage)** — when you need *how many unique values*, not a per-value list. The first `GROUPBY` collapses duplicates; the second counts the resulting rows.
```
# Q: "How many distinct brands sell mountain bikes?"
FT.AGGREGATE idx:bicycle "@type:{mountain}"
GROUPBY 1 @brand
GROUPBY 0 REDUCE COUNT 0 AS distinct_brand_count
DIALECT 2
```
**Bucket-by-derived-field (APPLY before GROUPBY).** When the bucket isn't a stored field — extract it with `APPLY`, then group on the alias.
```
# Q: "Bites per year"
FT.AGGREGATE idx:bites "*"
LOAD 1 @DateOfBite
APPLY year(@DateOfBite) AS year
GROUPBY 1 @year REDUCE COUNT 0 AS n
SORTBY 2 @year ASC
DIALECT 2
```
**Filter-after-derive (year extraction + filter).** When the question is "in YEAR X" but the field is a timestamp — `APPLY year(@ts) AS year` then `FILTER "@year == X"`. Don't try to express this in the query string; the query DSL has no `year()`.
```
# Q: "How many bites in 2016 by breed"
FT.AGGREGATE idx:bites "@Breed:rottweiler"
LOAD 1 @DateOfBite
APPLY year(@DateOfBite) AS year
FILTER "@year == 2016"
GROUPBY 0 REDUCE COUNT 0 AS n
DIALECT 2
```
**Multi-stage rule:** every stage produces a flat row of `(name, value)` pairs. Downstream stages only see names that earlier stages emitted — either a `LOAD`ed field, a `GROUPBY` key, a `REDUCE … AS alias`, or an `APPLY … AS alias`. Reference fields via their *current* alias, not the source path.
## APPLY functions
`APPLY` accepts a fixed allowlist of math, string, and time functions. **`round`, `now()`, and `date()` do not exist** — invoking them returns `Unknown function name`.
| Category | Functions |
|----------|-----------|
| Math | `ceil`, `floor`, `abs`, `log`, `exp`, `sqrt`, `pow`, `mod` |
| String | `substr`, `format`, `upper`, `lower`, `matched_terms`, `contains`, `startswith`, `strlen` |
| Time | `parse_time`, `day`, `month`, `year`, `monthofyear`, `dayofweek`, `dayofmonth`, `dayofyear`, `hour`, `minute`, `timefmt` |
| Geo | `geodistance` |
**No `round` — emulate via `floor` / `ceil`.**
```
# Round @weight to 2 decimal places
APPLY floor(@weight * 100) / 100 AS weight_rounded
```
**`substr(s, start, length)` — 0-indexed start, length is the number of characters to take (not the end position).** ISO dates `YYYY-MM-DD`:
```
APPLY substr(@date, 0, 4) AS year # YYYY
APPLY substr(@date, 5, 2) AS month # MM
APPLY substr(@date, 8, 2) AS day # DD
APPLY substr(@date, 0, 7) AS yyyy_mm # YYYY-MM
```
**`contains` for TEXT substring filtering** — the query-string form `-@field:value` does *not* negate substrings on TEXT. Use `contains` in a pipeline `FILTER`:
```
# "Cities NOT containing 'ile'"
FT.AGGREGATE idx:cities "*"
LOAD 1 @city
FILTER "!contains(@city, 'ile')"
DIALECT 2
```
**Incorrect:** Filtering *after* grouping when you meant to filter the source rows; mismatched `n` count on `GROUPBY`/`SORTBY`; loading every field "just in case."
```
# Bad: FILTER after GROUPBY filters group rows, not source rows.
# Intent was "only new bikes," but here you keep all groups and trim brand rows by mean price.
FT.AGGREGATE idx:bicycle "*"
GROUPBY 1 @brand REDUCE AVG 1 @price AS avg_price
FILTER "@condition == 'new'" # @condition no longer exists post-group!
DIALECT 2
# Bad: GROUPBY count mismatched — RESP parse error or surprising grouping
FT.AGGREGATE idx:bicycle "*"
GROUPBY 2 @brand # said 2 fields but only listed 1
REDUCE COUNT 0 AS n
DIALECT 2
# Bad: LOAD * inflates the pipeline payload on every doc
FT.AGGREGATE idx:bicycle "*" LOAD * GROUPBY 1 @brand REDUCE COUNT 0 AS n DIALECT 2
```
## Client mirrors
```python
# redis-py — STEP_START aggregate_pipeline
# Mirrors doctests/query_agg.py
from redis import Redis
from redis.commands.search.aggregation import AggregateRequest
from redis.commands.search.reducers import count, avg, sort_by
r = Redis()
req = (
AggregateRequest("@type:{mountain}")
.load("@brand", "@price", "@condition")
.apply(sale_price="@price * 0.9")
.filter("@condition == 'new'")
.group_by("@brand", count().alias("bike_count"), avg("@price").alias("avg_price"))
.sort_by(("@avg_price", "DESC"))
.limit(0, 5)
.dialect(2)
)
results = r.ft("idx:bicycle").aggregate(req)
# STEP_END
```
```java
// Jedis — STEP_START aggregate_pipeline
// Mirrors QueryAggExample.java
import redis.clients.jedis.UnifiedJedis;
import redis.clients.jedis.search.aggr.AggregationBuilder;
import redis.clients.jedis.search.aggr.Reducers;
import redis.clients.jedis.search.aggr.SortedField;
try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
AggregationBuilder agg = new AggregationBuilder("@type:{mountain}")
.load("@brand", "@price", "@condition")
.apply("@price * 0.9", "sale_price")
.filter("@condition == 'new'")
.groupBy("@brand",
Reducers.count().as("bike_count"),
Reducers.avg("@price").as("avg_price"))
.sortBy(SortedField.desc("@avg_price"))
.limit(0, 5)
.dialect(2);
jedis.ftAggregate("idx:bicycle", agg);
}
// STEP_END
```
## Upstream sources
- redis-py: [`doctests/query_agg.py`](https://github.com/redis/redis-py/blob/master/doctests/query_agg.py)
- Jedis: [`QueryAggExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryAggExample.java)
- Reference: [FT.AGGREGATE](https://redis.io/docs/latest/commands/ft.aggregate/), [Aggregations](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/aggregations/)
SHA-256: 46f91b646acc824360e4e8e723ab9fb82c31c7f3e6185789e4b20088a23cd393