Skip to main content

searchselect

A searchable multi-select anywidget for notebooks. Give it a list of strings; the user searches, filters and ticks; you read the result back in Python.

It is deliberately domain-neutral — it knows nothing but strings. Column names, category values, file paths, feature names, whatever you have a list of.

SearchSelect

pip install searchselect

Quickstart

from searchselect import SearchSelect

picker = SearchSelect(items=df.columns)
picker

Then, in a later cell:

df.select(picker.selected)

Works in JupyterLab, Jupyter Notebook, marimo Notebook, VS Code and anywhere else anywidget renders.

API

SearchSelect(items=None, selected=None)

Trait Direction Meaning
items read / write The strings on offer.
selected read / write The items the user has explicitly ticked.
query read / write The text in the search box.
regex read / write Treat query as a regex rather than a substring.
case_sensitive read / write Whether matching distinguishes case.
filtered read The items matching the current query.
query_error read Python's re.error text, or "" if valid.
theme read / write "auto" (default), "light" or "dark".

The distinction that matters: selected is an explicit choice, filtered is a query result. They are independent, and both are useful.

# Tick things by hand, then read them back.
picker.selected            # -> ['age', 'height']

# Or don't tick anything: type a pattern and take everything it matched.
picker.filtered            # -> ['lab_glucose', 'lab_sodium', ...]

# The query is writable too, so you can drive it from Python.
picker.query = "lab_"

The header checkbox selects everything matching the current query, not just what's on screen — so query plus select-all is the click-free route to the same set filtered reports.

Rules

  • Filtering never unselects. Ticking an item and then typing a search that excludes it leaves it in selected. The search box changes what you can see, not what you have chosen.
  • An empty search box means every item. filtered is the full list when nothing is typed, not an empty list.
  • Duplicates are dropped. First occurrence wins; order is otherwise preserved.
  • Selection is keyed on the item itself, not its position. Reassigning items keeps ticks on items that still exist and drops the rest.
  • selected is writable. Set it from Python to preselect or clear; the checkboxes follow. Values not present in items are ignored.
  • An unparseable query matches nothing. Which is not the same as an empty query, which matches everything. query_error carries the reason.

Searching

Two toggles sit next to the search box:

  • Case-insensitive matching.
  • Regex matching. An invalid pattern is reported inline, with Python's own error message, and matches nothing until it parses.

Matching runs in Python, using re. That means the pattern you type is a Python regex — (?P<name>...) works, \d and \w are Unicode as they are everywhere else in your code, and you can paste the pattern straight into the next cell and get identical results:

picker.regex = True
picker.query = r"^lab_(?P<assay>\w+)"

# the same pattern, same semantics, in your own code
[c for c in df.columns if re.match(picker.query, c)]

The trade-off is that searching needs a responsive kernel. If a long cell is running, the search box will not update until it finishes. Filtering in the browser would stay live, but it would mean a JavaScript regex — a different dialect that quietly rejects (?P<name>...) and treats \d as ASCII-only — and a pattern you can't reuse.

The list

The list is virtualised, so only the visible rows exist in the DOM however long it is.

  • The header retracts as you scroll down and returns the moment you scroll back up, so a long list gets the whole box without putting the sort control out of reach.
  • Sorting cycles A–Z, Z–A, then back to the original order — which is the order you passed items in, since that order is preserved.
  • The header checkbox selects every match, not just what is on screen.

Theme

By default the widget infers light or dark from the host notebook. That is inference, not a lookup — there is no standard way for a host to announce its theme — so pin it when the guess is wrong, when a notebook will be read by someone whose OS setting you don't know, or simply out of preference:

picker.theme = "light"   # or "dark", or "auto"

SearchSelect in dark mode

In VS Code, prefer "light". VS Code paints the widget output area white whatever the editor theme is, and that area is outside the widget, so a dark widget ends up sitting on a white surround. The widget can only paint up to its own edge; "light" removes the mismatch.

Copying out

Below the table is a panel rendering the current selection as a Python list or a Polars expression, with a copy button — for when you would rather paste a literal into your next cell than reference the widget. This is a convenience; picker.selected is the real API, and the package has no dataframe dependency of any kind.

Performance

The list is virtualised: only the rows on screen exist in the DOM, about 25 of them, whether you pass two hundred items or a million. Scrolling and rendering are therefore flat in the size of the list.

Two things are not flat, and they set the practical ceiling:

  • Transport. Items are sent to the frontend once, as JSON over the Jupyter comm — roughly 22 MB at a million items. Matches travel back only while a query is active; an empty query means "everything", and the frontend already has that list, so the resting state costs nothing.
  • Matching. Each query is a scan in Python: about 2 ms at ten thousand items, 60 ms at a hundred thousand, and up to 600 ms at a million for an expensive pattern. Input is debounced, so a keystroke does not become a request, but this is what you feel first on very large lists.

In practice: comfortable into the tens of thousands, usable past that, sluggish around a million. If you need to go further, open an issue — pushing the search into DuckDB and serving the visible window with LIMIT/OFFSET would break both ceilings at once, though it would change the pattern dialect to RE2 and cost you the Python-regex property above.

Development

Development requires uv and pnpm.

uv venv
uv pip install -e . --group dev
pnpm dev # start a development server
uv run jupyter lab notebooks/example.ipynb

pnpm dev and pnpm build write to the same directory, and the dev bundle is unminified with an inline sourcemap — roughly six times the size. pnpm build empties that directory first and the packaging hook runs it for the sdist, so uv build always produces a real production bundle regardless of what a dev server left behind.

Releasing:

uv build
uvx twine upload dist/*

License

MIT

Metadata

Release files for searchselect 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for searchselect 0.1.0
File Size Uploaded
searchselect-0.1.0.tar.gz 122.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for searchselect 0.1.0
File Interpreter ABI Platform
searchselect-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 245.4 kB

Release files / searchselect-0.1.0.tar.gz

Download URL searchselect-0.1.0.tar.gz
Size 122.3 kB
Tags Source
SHA-256 checksum
How to use checksums
6f7b2d608e8735b82eac0aa730f8c981b89eb6d425e101fcca81abf5b98e8992
BLAKE2b-256 checksum
How to use checksums
123de35506c24f23c2049c6430fe3e936457348e6de7924847b8ff5a33ebfafb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.

Transparency log

Release files / searchselect-0.1.0-py3-none-any.whl

Download URL searchselect-0.1.0-py3-none-any.whl
Size 123.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a73d1a2a1943e90cd8d1870896880f7b833c10ee0b9dec622f111f288d05a791
BLAKE2b-256 checksum
How to use checksums
b45392b07904b7a14298df296d1e73ab3e2c1ec6c50b1f99f26967c7f877a24e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page