Full-text search is not only about words. The moment you index real documents you need to
answer questions like "products under $50", "orders from last quarter", or "posts
between two dates". Whoosh — the pure-Python search library — has first-class support for
numeric and date ranges, but the API has a few sharp edges worth knowing. Here's a practical
tour, every snippet runnable against Whoosh 3.52.
pip install whoosh3— the actively-maintained fork of Whoosh. Every snippet below is verified against the current release (3.52.1).
Declare numeric and date fields
Ranges only work on fields that store their values in a sortable, range-friendly encoding.
That means NUMERIC for ints/floats and DATETIME for timestamps:
from whoosh.fields import Schema, ID, NUMERIC, DATETIME
schema = Schema(
id=ID(stored=True),
price=NUMERIC(float, stored=True, sortable=True),
stock=NUMERIC(int, signed=False, stored=True),
when=DATETIME(stored=True),
)
A few things to notice:
-
NUMERIC(float, ...)— pass the Python type you'll store. Default isint. -
signed=Falseshrinks the encoding when you know values are non-negative (counts, ages). -
sortable=Truelets you sort results by that field later — handy alongside ranges. -
DATETIMEis just aNUMERICsubclass that encodesdatetimeobjects as longs, so everything below applies to dates too.
Range queries in Python
The direct API is whoosh.query.NumericRange. Bounds are inclusive by default, and either
end can be None for an open-ended range:
from whoosh import query
# 20 <= price <= 100
q = query.NumericRange("price", 20, 100)
# price >= 50 (open upper bound)
q = query.NumericRange("price", 50, None)
# exclusive bounds: 10 < price < 99.5
q = query.NumericRange("price", 10, 99.5, startexcl=True, endexcl=True)
For dates, use whoosh.query.DateRange with datetime objects:
from datetime import datetime
q = query.DateRange("when", datetime(2022, 1, 1), datetime(2026, 12, 31))
Range queries from the query parser
Most apps take query strings from users. Whoosh's QueryParser understands the classic
[lo to hi] bracket syntax out of the box, and two plugins make it far nicer.
Bracket ranges
from whoosh.qparser import QueryParser
qp = QueryParser("id", schema)
qp.parse("price:[20 to 100]") # inclusive
qp.parse("price:{20 to 100}") # exclusive (curly braces)
qp.parse("price:[50 to]") # open upper bound
>/< shorthand with GtLtPlugin
For single-sided filters, the GtLtPlugin turns comparison operators into ranges:
from whoosh.qparser import QueryParser, GtLtPlugin
qp = QueryParser("id", schema)
qp.add_plugin(GtLtPlugin())
qp.parse("price:>50") # NumericRange('price', 50, None, startexcl=True)
qp.parse("price:<=20") # NumericRange('price', None, 20)
Human dates with DateParserPlugin
Typing ISO datetimes is miserable. DateParserPlugin lets users write natural date
expressions, and it resolves shorthands to the right span automatically:
from whoosh.qparser.dateparse import DateParserPlugin
qp = QueryParser("id", schema)
qp.add_plugin(DateParserPlugin())
qp.parse("when:2023") # the whole year 2023
qp.parse("when:[2022 to 2026]") # a multi-year span
qp.parse("when:[2023-01 to 2023-06]")
when:2023 expands to a DateRange covering 2023-01-01 00:00:00 through
2023-12-31 23:59:59.999999 — exactly what a user means by "in 2023." This "granularity"
behavior is the plugin's best feature: a bare year, month, or day becomes the span it
denotes, not a single instant.
A complete, runnable example
from datetime import datetime
from whoosh.fields import Schema, ID, NUMERIC, DATETIME
from whoosh.filedb.filestore import RamStorage
from whoosh.qparser import QueryParser, GtLtPlugin
from whoosh.qparser.dateparse import DateParserPlugin
schema = Schema(id=ID(stored=True),
price=NUMERIC(float, stored=True),
when=DATETIME(stored=True))
ix = RamStorage().create_index(schema)
w = ix.writer()
w.add_document(id="a", price=10.0, when=datetime(2020, 1, 1))
w.add_document(id="b", price=50.0, when=datetime(2023, 6, 15))
w.add_document(id="c", price=99.5, when=datetime(2025, 3, 3))
w.commit()
qp = QueryParser("id", schema)
qp.add_plugin(GtLtPlugin())
qp.add_plugin(DateParserPlugin())
with ix.searcher() as s:
for q in ["price:>50", "price:[10 to 60]", "when:2023", "when:[2024 to 2026]"]:
hits = sorted(h["id"] for h in s.search(qp.parse(q)))
print(f"{q:20} -> {hits}")
Output:
price:>50 -> ['c']
price:[10 to 60] -> ['a', 'b']
when:2023 -> ['b']
when:[2024 to 2026] -> ['c']
Gotchas worth knowing
-
Ranges need a numeric/date field. Putting a range on a
TEXTfield does a lexicographic range over terms, which is almost never what you want for numbers —"10"sorts before"9". Always useNUMERIC/DATETIMEfor real quantities. -
Store what you'll sort or display. Range matching works without
stored=True, but you need it to read values back from hits, andsortable=Trueif you'll sort by them. -
Combine freely. Range queries are ordinary queries —
And,Or, and text queries compose with them, socontent:laptop AND price:[500 to 1500]just works. -
Timezones.
DateParserPluginresolves to UTC by default; store yourdatetimes consistently (naive-UTC or tz-aware) to avoid off-by-hours surprises. -
A clock time on a span is rejected, not guessed. The granularity behavior above is
great for a bare
when:2023, but a span-precision date plus a time of day is genuinely ambiguous —when:"2023 15:00"can't mean "all of 2023" and "15:00" at once, and a single contiguous range can't express "15:00 on every day of 2023." Rather than silently invent a range, the parser declines:date_from("2023 15:00")(and"2023-05 15:00","may 2023 15:00","this month 15:00") returnsNone, so that clause simply matches nothing instead of quietly widening to something you didn't ask for. If you want a time, give a full day-precision date (when:"2023-05-04 15:00"resolves cleanly). Good defensive behavior to know about if you surface a raw date box to users — validate empty results rather than assuming every input parsed.
Wrapping up
Numeric and date ranges are where a search index starts replacing hand-rolled SQL WHERE
clauses. With NUMERIC/DATETIME fields plus the GtLtPlugin and DateParserPlugin,
Whoosh gives users an expressive, forgiving filter syntax in a few lines — no external
search server required.
whoosh3 is the maintained fork of Whoosh (pure-Python, BM25, Apache-licensed). Issues, ideas,
and PRs are genuinely welcome on GitHub.
If range queries save you a database round-trip, a ⭐ helps others find the library.