The (s >= 20) & (s <= 40) bracket dance is one call you already have: between() reads like the range you mean.
There is a two-line boolean dance in nearly every pandas report that collapses into one method call — and almost nobody uses it.
between() answers one question on a Series: is each value inside a range? s.between(20, 40) returns a boolean Series — True wherever 20 <= value <= 40, False elsewhere, and False at NaN positions. Boundaries are inclusive by default, and the inclusive argument ("both", "neither", "left", "right") switches each side independently. The real flex is that left and right can be lists: per-row boundaries, each row checked against its own band. It is exactly SQL's BETWEEN predicate, implemented over an array.
Range filters are everywhere in reporting: delivery times inside the SLA, order values in a band, timestamps inside a reporting window. The manual idiom — (s >= 20) & (s <= 40) — repeats the Series twice, demands parentheses because & binds tighter than >=, and grows into noise with every added condition. between() writes the intent once and stays readable at any complexity; per-row boundaries via list arguments solve the case where every customer has its own SLA band. It composes with everything: pass the mask to df[mask] or feed it straight into query().
deliveries = pd.DataFrame({
"city": ["Rotterdam", "Utrecht", "Eindhoven", "Groningen", "Arnhem", "Maastricht"],
"orders": [412, 87, 190, 45, 333, 128],
"delivery_min": [23, 41, 19, 52, 34, 38],
})
deliveries[deliveries["delivery_min"].between(20, 40)]city orders delivery_min 0 Rotterdam 412 23 4 Arnhem 333 34 5 Maastricht 128 38
The daily-driver move: every city whose delivery time lands in the 20–40 minute band, one call. Boundaries included.
deliveries["orders"].between(100, 400, inclusive="neither")
0 False 1 False 2 True 3 False 4 True 5 True Name: orders, dtype: bool
Open interval: 100 and 400 themselves are now False. four of the six cities sit strictly inside the band.
sla = {"Rotterdam": (20, 35), "Utrecht": (30, 45), "Eindhoven": (15, 25),
"Groningen": (40, 60), "Arnhem": (30, 40), "Maastricht": (30, 40)}
lo, hi = zip(*deliveries["city"].map(sla))
deliveries["delivery_min"].between(lo, hi)0 True 1 True 2 True 3 True 4 True 5 True Name: delivery_min, dtype: bool
The killer feature: pass lists as boundaries and every row is checked against its own SLA band — the chained-comparison idiom cannot do this in one expression.
deliveries.query("delivery_min.between(20, 40) and orders >= 100")city orders delivery_min 0 Rotterdam 412 23 4 Arnhem 333 34 5 Maastricht 128 38
between() is a recognized function inside query() expressions — combine it with and/or for compound filters.
deliveries[deliveries["placed"].between("2026-09-01", "2026-09-05")]
# placed: pd.to_datetime(["2026-09-01", "2026-09-03", "2026-09-05",
# "2026-09-06", "2026-09-07"])order_id placed amount 0 A-1042 2026-09-01 89.50 1 A-1043 2026-09-03 42.00 2 A-1044 2026-09-05 130.25
Dates too: pandas coerces the strings, and both endpoint days are kept. The classic 'report window' filter, without building Timestamps.
| Flag | Meaning |
|---|---|
s.between(20, 40) | the core call — True wherever 20 <= value <= 40 (inclusive by default) |
inclusive="neither" | "left" | "right" | "both" | open each boundary independently; "both" is the default |
s.between(lo_list, hi_list) | list boundaries: each row compared against its own band — per-row ranges in one call |
df[df["col"].between(a, b)] | the standard composition: boolean Series in, filtered DataFrame out |
df.query("col.between(a, b)") | use between() inside query() expressions for compound readable filters |
df[df["date"].between("2026-01-01", "2026-03-31")] | on datetime columns pandas parses the strings for you — no Timestamp construction needed |
df[df["col"].between(a, b)].copy() | the mask pattern slices a view; .copy() when the result needs independent edits |
Series.between() shipped way back in pandas 0.8.0 (June 2012). It predates the 0.13 (2014) release that gave us query() — so the readable range filter existed for two years before query() arrived to wrap it in a string dialect. There is no DataFrame.between(); the method only exists on Series.
The keyword that controls the endpoints was called boundary= until pandas 1.3.0 (2021) renamed it to inclusive=, matching interval_range()'s naming. 1.3.0 kept boundary= working as a deprecated alias (with a FutureWarning); it was removed in 2.0 (2023). The four values themselves — "both", "neither", "left", "right" — never changed.
Series.between() is a thin wrapper: (left <= ser) & (ser <= right), evaluated in one vectorized pass with NaN handled by the comparisons returning False. No numexpr, no expression compiler — just two elementwise array ops. When left and right are arrays, pandas allocates a temporary Series from your lists and then delegates to flex_arith op wrappers; with the default engine, scalar boundaries on small frames may fall back to a Python-level loop instead of a C loop — a slow path that only shows up with object dtype, so keep your bands numeric and it stays C-speed.