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
- Search box → parse the query with
MultifieldParserand run it. - Filter sidebar → one
FieldFacet(orRangeFacet) per filter; render eachgroups()entry as a checkbox with itslen(...)count. - Clicking a filter → add a
Termto a combinedfilter=query and re-run; the other facet counts refresh from the narrowed result set. - Sort control → swap
sortedbybetween a facet (e.g. price) and default BM25 relevance.
When to use this
- Use it for catalog/e-commerce search, log and ticket dashboards, or any list where users expect filters with live counts — and you want it in pure Python with no external search service.
- Watch out for making faceted fields
TEXTby mistake: text fields are tokenized, so "Trail Running Shoes" would split into three buckets. UseKEYWORD(orID) for values you group on. - For very large indexes, faceting reads column values; keep faceted fields
as
KEYWORD/NUMERICand see the performance guide for indexing tips.
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