Faceted search & result counts with Whoosh

by Priya Sundaram · 20 July 2026
TL;DR. Faceting is the "12 in Footwear, 4 in Outerwear" counts you see next to search filters. In Whoosh you get them by passing a sorting.FieldFacet("category") to search(..., groupedby=…) and reading results.groups() — a dict of facet value → matching doc ids. Use RangeFacet for numeric price buckets, MultiFacet to cross two fields, and a filter= query to drill down when the user clicks a facet. It is all pure Python, no search server. Every snippet below was run against whoosh3 3.18 on Python 3.11 and the output is copied verbatim.

Almost every catalog, docs site, or admin table wants the same UX: a search box, a list of results, and a sidebar of filters that each show how many results fall under them. That count-next-to-filter pattern is called faceting. Whoosh has a first-class faceting API built around the whoosh.sorting module, so you do not need Elasticsearch or a separate analytics store to build it.

pip install whoosh3

1. A small catalog to search

Give faceted fields a type Whoosh can group on exactly. KEYWORD is ideal for categorical values like category and brand: it stores the value as a single token so each product lands in exactly one bucket. Keep prices NUMERIC so you can bucket and sort them.

import os, shutil
from whoosh import index
from whoosh.fields import Schema, TEXT, ID, KEYWORD, NUMERIC
from whoosh import sorting
from whoosh.query import Every, Term

d = "catalog_index"
shutil.rmtree(d, ignore_errors=True); os.mkdir(d)

schema = Schema(
    id=ID(stored=True, unique=True),
    name=TEXT(stored=True),
    category=KEYWORD(stored=True),   # facet field
    brand=KEYWORD(stored=True),      # facet field
    price=NUMERIC(stored=True, numtype=float),
)
ix = index.create_in(d, schema)

products = [
    ("1", "Trail Running Shoes", "footwear",    "Trailblaze",  89.0),
    ("2", "Road Running Shoes",  "footwear",    "Trailblaze", 120.0),
    ("3", "Waterproof Jacket",   "outerwear",   "Summit",     150.0),
    ("4", "Down Jacket",         "outerwear",   "Summit",     220.0),
    ("5", "Wool Socks",          "accessories", "Trailblaze",  15.0),
    ("6", "Running Cap",         "accessories", "Summit",      25.0),
    ("7", "Hiking Boots",        "footwear",    "Summit",     175.0),
]
w = ix.writer()
for pid, name, cat, brand, price in products:
    w.add_document(id=pid, name=name, category=cat, brand=brand, price=price)
w.commit()

2. Count results grouped by a field

To show "how many products are in each category" across the whole catalog, search the Every() query (which matches every document) and pass a FieldFacet as groupedby. Set limit=None so the counts cover the full result set, not just the first page. The counts live in results.groups(), a dict of facet value → list of matching doc ids — take len(...) for the badge number.

with ix.searcher() as s:
    r = s.search(Every(), groupedby=sorting.FieldFacet("category"), limit=None)
    print("Category counts (all products):")
    for value in sorted(r.groups().keys()):
        print(f"  {value}: {len(r.groups()[value])}")
Category counts (all products):
  accessories: 2
  footwear: 3
  outerwear: 2

3. Facet counts for an actual query

Faceting shines combined with a search: run the user's query, then group the hits. Here we search "running" and break the matches down by brand. The groups() counts always describe the current result set, so they update automatically as the query changes.

from whoosh.qparser import MultifieldParser

with ix.searcher() as s:
    q = MultifieldParser(["name", "category"], schema=ix.schema).parse("running")
    r = s.search(q, groupedby=sorting.FieldFacet("brand"))
    print("Search 'running' grouped by brand:")
    for brand, docids in r.groups().items():
        print(f"  {brand}: {len(docids)}")
    print("  hits:", [hit["name"] for hit in r])
Search 'running' grouped by brand:
  Trailblaze: 2
  Summit: 1
  hits: ['Running Cap', 'Trail Running Shoes', 'Road Running Shoes']

4. Numeric range facets (price buckets)

For a "Price" filter you want ranges, not one bucket per exact value. RangeFacet(fieldname, start, end, gap) slices a numeric field into even buckets. The group keys are (low, high) tuples you can format as labels like $50–$100.

with ix.searcher() as s:
    price_facet = sorting.RangeFacet("price", 0, 250, 50)
    r = s.search(Every(), groupedby=price_facet, limit=None)
    print("Price range buckets:")
    for rng in sorted(r.groups().keys(), key=lambda x: (x is None, x)):
        print(f"  {rng}: {len(r.groups()[rng])}")
Price range buckets:
  (0, 50): 2
  (50, 100): 1
  (100, 150): 1
  (150, 200): 2
  (200, 250): 1

5. Cross two facets with MultiFacet

Sometimes you want the combined breakdown — category and brand at once, e.g. for a matrix view. MultiFacet groups by several fields together and gives you tuple keys.

with ix.searcher() as s:
    multi = sorting.MultiFacet(["category", "brand"])
    r = s.search(Every(), groupedby=multi, limit=None)
    print("Category+Brand combos:")
    for key in sorted(r.groups().keys()):
        print(f"  {key}: {len(r.groups()[key])}")
Category+Brand combos:
  ('accessories', 'Summit'): 1
  ('accessories', 'Trailblaze'): 1
  ('footwear', 'Summit'): 1
  ('footwear', 'Trailblaze'): 2
  ('outerwear', 'Summit'): 2

6. Drill down when the user clicks a facet

When the shopper clicks "Footwear", you narrow the results to that value and re-compute the remaining facets on what is left. Pass the selected value as a filter= query (fast, cached, and it does not affect scoring) and keep grouping by the other fields. You can layer a full-text query and a sortedby on top of the same call.

from whoosh.qparser import QueryParser

with ix.searcher() as s:
    # user picked category = footwear -> re-facet remaining brands
    r = s.search(Every(), filter=Term("category", "footwear"),
                 groupedby=sorting.FieldFacet("brand"), limit=None)
    print("After drill-down category=footwear, brand counts:")
    for brand, ids in r.groups().items():
        print(f"  {brand}: {len(ids)}")

    # full-text query + facet filter + sort by price, all together
    q = QueryParser("name", ix.schema).parse("shoes")
    r2 = s.search(q, filter=Term("category", "footwear"),
                  sortedby=sorting.FieldFacet("price"))
    print("'shoes' in footwear, cheapest first:")
    for hit in r2:
        print(f"  {hit['name']}  ${hit['price']}")
After drill-down category=footwear, brand counts:
  Trailblaze: 2
  Summit: 1
'shoes' in footwear, cheapest first:
  Trail Running Shoes  $89.0
  Road Running Shoes  $120.0

How the pieces fit a real UI

When to use this

The same facet objects power grouping in a Flask or FastAPI search endpoint, and over a pandas DataFrame. For tokenization control on the text fields, see the analyzer recipes.

Whoosh is maintained again. It's pure Python, zero runtime dependencies, BSD-2-Clause, with a live browser demo and modern packaging.

⭐ Star it on GitHub  ·  pip install whoosh3  ·  Try the live demo