Faceting

This section documents the current Solr line (10.0; 9.10.x the maintained 9.x branch), written and verified against the Apache Solr Reference Guide. No specific patch version is pinned. Some capabilities (the Solr Operator on Kubernetes, the package-manager ecosystem, Learning To Rank model training, and expert plugin development) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A facet turns the documents a query matched into counts, or richer aggregations, grouped by field value, query, or range — computed in the same request that runs the search. Solr has two ways to ask for this: the original request-parameter facets (facet.field, facet.query, facet.range, facet.pivot, facet.interval), driven entirely by top-level query parameters, and the JSON Facet API, a nested JSON structure under the json.facet parameter that adds richer aggregations, true sub-facet nesting, and domain changes. This page covers both in full. For the request-body shape they both sit inside, see The JSON Request API; for collapsing result groups down to one document per group rather than counting them, see Grouping & collapse.

Enabling faceting

Set facet=true (or, in a JSON request body, a facet block is enough on its own — see below) to turn on the classic facet parameters. Every facet computed this way counts over the documents the main query matched, the same way an aggregation would; narrowing q or fq narrows every facet along with it.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.field=genre' \
  --data-urlencode 'facet.field=author'

rows=0 mirrors Elasticsearch’s size: 0: skip the hit list and pay only for the facet counts. See Faceting for the full parameter reference.

Field-value faceting — facet.field

facet.field builds one bucket per distinct value of a field, ordered by descending count by default. Repeat the parameter for multiple fields; per-field overrides use the f.<field>.facet.* prefix.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.field=genre' \
  --data-urlencode 'facet.limit=20' \
  --data-urlencode 'facet.mincount=1' \
  --data-urlencode 'facet.sort=count' \
  --data-urlencode 'f.genre.facet.limit=5'

Key parameters:

Parameter Effect

facet.limit

Max buckets returned (default 100; -1 for unlimited).

facet.offset

Skip this many buckets — crude pagination, no cursor.

facet.mincount

Drop buckets with fewer than this many matching documents (default 0).

facet.sort

count (default) or index (by term value, lexically/numerically).

facet.prefix

Only values starting with this literal prefix.

facet.contains

Only values containing this substring (facet.contains.ignoreCase).

facet.matches

Only values matching this regular expression.

facet.missing

Add a bucket counting documents with no value for the field.

facet.method chooses the counting algorithm: enum walks every term in the field and intersects its postings with the query’s matching set (best for low-cardinality fields, e.g. a status or category); fc ("field cache", now docValues-backed) iterates matching documents and increments a per-value counter (best for high-cardinality fields); fcs is a per-segment variant of fc for single-valued string fields that parallelizes across segments. Leave it unset and Solr picks a default per field type — override only after profiling.

Query faceting — facet.query

facet.query reports a single count — the number of matched documents also matching an arbitrary query — for each occurrence of the parameter. There is no field, no bucketing: each facet.query is its own independent yes/no filter over the result set, so it is the tool for counts that a facet.field/facet.range bucket can’t express directly (an OR of unrelated conditions, a function-query threshold).

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.query=price:[0 TO 20]' \
  --data-urlencode 'facet.query=price:[20 TO 50]' \
  --data-urlencode 'facet.query=in_stock:true AND price:[0 TO 20]'

Range faceting — facet.range

facet.range buckets a numeric, date, or currency field into fixed-width bands between facet.range.start and facet.range.end, stepped by facet.range.gap. facet.range.include (lower, upper, edge, outer, all) controls which side of each bucket boundary is inclusive; facet.range.other (before, after, between, none, all) adds buckets for values outside [start, end].

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.range=price' \
  --data-urlencode 'facet.range.start=0' \
  --data-urlencode 'facet.range.end=200' \
  --data-urlencode 'facet.range.gap=50' \
  --data-urlencode 'facet.range.other=after' \
  --data-urlencode 'facet.range=publish_date' \
  --data-urlencode 'facet.range.start=NOW/YEAR-5YEARS' \
  --data-urlencode 'facet.range.end=NOW/YEAR+1YEAR' \
  --data-urlencode 'facet.range.gap=+1YEAR'

Date math (NOW/YEAR-5YEARS, +1YEAR) works the same way it does everywhere else in Solr — see Query basics & parameters for the full date-math syntax.

Interval faceting — facet.interval

facet.interval counts documents against a fixed set of explicit brackets rather than a uniform gap, using mathematical interval notation: [ / ] for inclusive, ( / ) for exclusive. It scans the field once for however many facet.interval.set values are given, which makes it cheaper than several overlapping facet.query ranges over the same field.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.interval=price' \
  --data-urlencode 'facet.interval.set=[0,20)' \
  --data-urlencode 'facet.interval.set=[20,50)' \
  --data-urlencode 'facet.interval.set=[50,*]'

Pivot (decision tree) faceting — facet.pivot

facet.pivot nests field facets inside each other, comma-separated, producing a tree of buckets — the classic "facet within a facet" (category, then brand within each category). Each level can carry its own facet.limit/facet.mincount via f.<field>.facet.*, and a stats.field (see Query basics & parameters) can be attached per pivot to compute a sub-aggregation inside every leaf bucket.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'facet.pivot=genre,author' \
  --data-urlencode 'facet.pivot.mincount=1'

Pivot faceting predates the JSON Facet API’s nested sub-facets (below) and is still the shorter way to express a plain hierarchical breakdown when no per-level metric beyond count/stats.field is needed.

Multi-select faceting — tag and ex local params

The usual faceted-search pattern needs facet counts computed before the user’s own selection is applied, so a checkbox for "red" still shows the count for every other colour too. Solr does this with local params: tag a filter with \{!tag=colors} in fq, then exclude that tag from a specific facet with \{!ex=colors} on facet.field (or facet.query/facet.range) so the facet is computed against the query alone, ignoring that one filter.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=running shoe' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'fq={!tag=colorFilter}color:red' \
  --data-urlencode 'facet.field={!ex=colorFilter}color'

color still counts every colour matched by the query; the hit list (and any other facet without a matching \{!ex=}) stays narrowed to red. Multiple tags can be excluded at once (\{!ex=colorFilter,sizeFilter}), and a single fq can carry several tags. This is the request-parameter equivalent of Elasticsearch’s post_filter — see Aggregations — except here every facet opts out of a named filter individually rather than the whole aggregation block running before one shared post_filter.

facet.field key — relabeling a bucket

\{!key=…​} renames a facet’s key in the response, independently of ex/tag — combine them in one local-params block: facet.field=\{!ex=colorFilter key=colors}color. This is what lets a client issue the same field faceted twice with different exclusions (e.g. one narrowed, one not) without a response key collision.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'facet=true' \
  --data-urlencode 'fq={!tag=colorFilter}color:red' \
  --data-urlencode 'facet.field={!ex=colorFilter key=colors_all}color' \
  --data-urlencode 'facet.field={!key=colors_selected}color'

The JSON Facet API

The JSON Facet API replaces the flat facet.* parameters with a nested JSON object under json.facet (either as a request parameter or a top-level key in a JSON request body — see The JSON Request API). It covers the same field/query/range/heatmap facet types as the classic parameters, but adds real nesting (a facet’s facet block can itself contain more facets, each seeing only its parent’s bucket), a much larger set of metric functions, per-facet domain changes, and streaming/large-cardinality execution modes. See JSON Facet API.

curl "http://localhost:8983/solr/books/select" \
  --data-urlencode 'q=*:*' \
  --data-urlencode 'rows=0' \
  --data-urlencode 'json.facet={
    "genres": {
      "type": "terms",
      "field": "genre",
      "limit": 20,
      "mincount": 1
    }
  }'

terms — group by field value

type: terms is the JSON equivalent of facet.field, with the same limit/offset/mincount/ sort/prefix knobs as JSON keys instead of facet.* parameters, plus numBuckets (return the total distinct-value count alongside the page) and allBuckets (add a rollup bucket summing every bucket, matched or not, including ones dropped by limit).

{
  "top_authors": {
    "type": "terms",
    "field": "author",
    "limit": 10,
    "sort": { "avg_price": "desc" },
    "numBuckets": true,
    "facet": {
      "avg_price": "avg(price)",
      "book_count": "unique(isbn)"
    }
  }
}

range — numeric or date bands

type: range mirrors facet.range: field, start, end, gap, hardend, include, other. It also accepts ranges, an explicit array of { "from": …​, "to": …​, "inclusive_from": …​, "inclusive_to": …​ } objects, when the bands aren’t evenly spaced — something facet.range alone cannot express without falling back to facet.interval.

{
  "price_bands": {
    "type": "range",
    "field": "price",
    "start": 0,
    "end": 200,
    "gap": 50,
    "other": "after"
  },
  "custom_bands": {
    "type": "range",
    "field": "price",
    "ranges": [
      { "from": 0, "to": 20 },
      { "from": 20, "to": 50, "inclusive_to": true },
      { "from": 50, "to": null }
    ]
  }
}

query — a single bucket from a filter

type: query produces one bucket — the subset of the domain also matching q — to scope metrics or sub-facets to a condition, the JSON equivalent of facet.query but able to carry nested facet and domain blocks of its own.

{
  "cheap_in_stock": {
    "type": "query",
    "q": "price:[0 TO 20] AND in_stock:true",
    "facet": {
      "count_by_genre": { "type": "terms", "field": "genre" }
    }
  }
}

heatmap — 2D spatial grid

type: heatmap counts documents into a grid of geospatial cells over a spatial field, returning a compact counts matrix (or PNG) suitable for rendering a density overlay on a map. geom bounds the region, gridLevel (or distErrPct) controls cell resolution. See Spatial search for the spatial field types it runs over.

{
  "store_density": {
    "type": "heatmap",
    "field": "store_location",
    "geom": "[\"-180,-90\" TO \"180,90\"]",
    "gridLevel": 3
  }
}

Metric aggregations and facet functions

Any facet bucket — the top-level domain included — can carry sibling keys that are metric expressions instead of nested facets: sum(field), avg(field), min(field), max(field), unique(field) (exact distinct count within the bucket, unlike Elasticsearch’s approximate cardinality), percentile(field,50,95,99), variance(field), stddev(field), and missing(field). These are the leaves of the tree, exactly the way an Elasticsearch metric aggregation is always a leaf under a bucket aggregation — see Aggregations.

{
  "by_genre": {
    "type": "terms",
    "field": "genre",
    "facet": {
      "revenue":     "sum(price)",
      "avg_price":   "avg(price)",
      "distinct_authors": "unique(author)",
      "price_p95":   "percentile(price,95)"
    }
  }
}

uniqueBlock(…​) is a specialized, cheaper unique for counting parent documents from within a nested-document (block-join) child domain — it counts blocks without touching the filter cache the way a generic unique on a join-derived field would.

Nested sub-facets

Any facet’s facet key can itself hold more named facets, each computed within that bucket’s own subset of the domain — the same relationship a terms sub-aggregation under another terms has in Elasticsearch. There is no fixed depth limit; watch the bucket product across levels the same way you would there.

{
  "by_genre": {
    "type": "terms",
    "field": "genre",
    "limit": 10,
    "facet": {
      "by_year": {
        "type": "range",
        "field": "publish_year",
        "start": 1990,
        "end": 2030,
        "gap": 10,
        "facet": {
          "revenue": "sum(price)"
        }
      }
    }
  }
}

domain — changing what a facet counts over

By default every facet’s domain is its parent’s bucket (and the root facet’s domain is the main query’s matches). A domain block overrides that:

Key Effect

filter

Extra query/queries ANDed onto the domain — can reference a tagged fq via \{!query v=$paramname} or just embed a query string.

excludeTags

Drop one or more tagged fq filters from the domain — the JSON Facet equivalent of local-params \{!ex=…​}, but named per facet instead of per facet.field.

blockChildren

Descend from parent documents into their nested child documents (a query matching the parent documents, e.g. "content_type:product") — the JSON Facet equivalent of \{!child of=…​}.

blockParent

Climb from child documents back up to their parent documents — the equivalent of \{!parent which=…​}, and of Elasticsearch’s reverse_nested.

join

Move the domain across a \{!join}-style relationship by from/to field, without the child/parent block-join structure.

query

Replace the domain outright with an arbitrary query’s matches, ignoring the parent bucket.

{
  "in_stock_facets": {
    "type": "terms",
    "field": "genre",
    "domain": {
      "excludeTags": ["colorFilter"],
      "filter": "in_stock:true"
    }
  },
  "review_ratings_by_product_genre": {
    "type": "terms",
    "field": "rating",
    "domain": {
      "blockParent": "content_type:product"
    },
    "facet": {
      "product_count": "uniqueBlock(_root_)"
    }
  }
}

blockChildren/blockParent need the same parent/child block-join document layout used by the \{!child of=…​}/\{!parent which=…​} query parsers covered in Partial updates & concurrency. Cross-collection or cross-field lookups that aren’t a parent/child block use join instead, mirroring the \{!join} query parser from Core concepts & architecture.

method and streaming options

Every bucketing facet type accepts a method: dv (docValues, iterate matching documents and increment per-value counters — the JSON Facet equivalent of facet.method=fc/fcs), dvhash (docValues with a hash table, better for very high cardinality), stream (walk indexed terms in sorted order and stream results without collecting every bucket in memory — unbounded cardinality at the cost of losing sort-by-metric, since buckets emit in key order), uif ("UnInvertedField", legacy field-cache faceting), and smart (the default — Solr picks per field/query shape). stream requires sort: "index asc" and no facet.limit-style paging; it exists for the same reason Elasticsearch’s composite aggregation does — enumerating every bucket of a huge-cardinality field without the top-N accuracy caveat that a bounded terms/facet.field carries.

{
  "every_sku": {
    "type": "terms",
    "field": "sku",
    "method": "stream",
    "sort": { "index": "asc" }
  }
}

relatedness — semantic knowledge graphs

relatedness(foreground, background) scores how strongly a bucket’s values correlate with a foreground query relative to a background query — e.g. which genres are disproportionately common among five-star reviews compared to all reviews. It returns a relatedness_value (roughly -1 to 1) plus the raw foreground_count/foreground_size/background_count/background_size it was computed from, and accepts min_popularity (skip buckets too small to be statistically meaningful) and sweep_collection (tune the two-pass execution strategy for large domains).

{
  "genre_vs_five_star": {
    "type": "terms",
    "field": "genre",
    "facet": {
      "relatedness_to_5star": {
        "type": "func",
        "func": "relatedness($fg,$bg)"
      }
    }
  },
  "fg": { "type": "query", "q": "rating:5", "excludeTags": "genreFilter" },
  "bg": { "type": "query", "q": "*:*" }
}

This is the tool for "what stands out" questions — it has no direct classic-facet or Elasticsearch aggregation equivalent; the closest analogue on the Elasticsearch side is the significant_terms aggregation.

Classic facets vs. the JSON Facet API

Reach for the classic facet.* parameters for a flat, one- or two-level breakdown (facet.field, facet.range, or a facet.pivot of two or three fields) where \{!tag}/\{!ex} multi-select is enough — they are shorter to write as URL parameters and every Solr client already knows them. Reach for json.facet once you need a metric other than count inside a bucket, more than one level of real nesting, a domain change, relatedness, or method: stream over a huge-cardinality field — none of those are expressible with the classic parameters at all. The two can be combined in the same request (classic facet.field alongside a json.facet block), but a given facet is defined with one mechanism or the other, not both. For the general shape of a JSON request body both live inside, see The JSON Request API; for turning result groups (rather than counts) into one row per group, see Grouping & collapse.