Filtering by numbers and dates in Whoosh: range queries done right

python dev.to

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),
)
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • NUMERIC(float, ...) — pass the Python type you'll store. Default is int.
  • signed=False shrinks the encoding when you know values are non-negative (counts, ages).
  • sortable=True lets you sort results by that field later — handy alongside ranges.
  • DATETIME is just a NUMERIC subclass that encodes datetime objects 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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

>/< 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)
Enter fullscreen mode Exit fullscreen mode

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]")
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Output:

price:>50            -> ['c']
price:[10 to 60]     -> ['a', 'b']
when:2023            -> ['b']
when:[2024 to 2026]  -> ['c']
Enter fullscreen mode Exit fullscreen mode

Gotchas worth knowing

  • Ranges need a numeric/date field. Putting a range on a TEXT field does a lexicographic range over terms, which is almost never what you want for numbers — "10" sorts before "9". Always use NUMERIC/DATETIME for 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, and sortable=True if you'll sort by them.
  • Combine freely. Range queries are ordinary queries — And, Or, and text queries compose with them, so content:laptop AND price:[500 to 1500] just works.
  • Timezones. DateParserPlugin resolves to UTC by default; store your datetimes 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") returns None, 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.

Source: dev.to

arrow_back Back to Tutorials