pandas between() — one call for range filters, instead of chained comparisons

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.

What it does

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.

Why it matters

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().

Examples

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.

Flags

FlagMeaning
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 has been in pandas since 0.8.0 (June 2012)

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.

Design note: the boundary= → inclusive= rename (pandas 1.3.0)

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.

Under the hood: one vectorized pass, then a scalar fallback

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.

Fun facts

Pros

Cons

Takeaways