Rust-powered functional toolkit for Python 3.13+: the toolz
API you know, an Option/Result you've wanted, and a fluent Iter — all
executing in native code.
pip install dizzle # or: uv add dizzle# 1. toolz-compatible flat functions — switch by changing an import
from dizzle import groupby, frequencies, merge, pipe, curried
groupby(len, ["a", "bb", "cc", "d"]) # {1: ['a', 'd'], 2: ['bb', 'cc']}
# 2. Option/Result — partiality without try/except
from dizzle.option import Option, get_in_opt, Some
loc = (get_in_opt(payload, "results", 0, "geometry", "location")
.map(Location.from_dict)
.unwrap_or(None))
match Option.of(user.email):
case Some(email): send(email)
case _: skip()
# 3. Fluent Iter — Rust-iterator ergonomics, single-pass, lazy
from dizzle import Iter
(Iter(rows)
.filter(lambda r: r["active"])
.pluck("city")
.frequencies())- Flat layer matches toolz exactly — including exceptions (
first([])raises). Option-returning twins (first_opt,get_in_opt, …) and allIterpartial terminals never raise on missing data.- Your callbacks' exceptions always propagate — nothing is swallowed.
uv sync # builds the Rust extension via maturin
uv run pytest # oracle + property tests against toolz
uv run pytest benches --benchmark-only # vs cytoolzInformational benchmarks against cytoolz and toolz live in benches/ and run explicitly:
uv run pytest benches --benchmark-onlyAs of 2026-08-27 (Python 3.13, Apple Silicon; median mean-time of 3 runs, ratio = dizzle/cytoolz, lower is better). The five benchmarks of the 0.4.0 suite hold at geometric mean 0.79x (~21% faster than cytoolz); the broad 16-group suite of 0.5.0, deliberately including the worst cases, sits at 1.07x; the two int64-buffer groups added in 0.6.0 land at 0.07x / 0.10x (10–14x faster), putting the full 18-group suite at 0.81x:
| benchmark | dizzle | cytoolz | toolz | vs cytoolz |
|---|---|---|---|---|
frequencies (30k int64 buffer) |
52 us | 756 us | 1164 us | 0.07x |
unique (30k int64 buffer) |
36 us | 363 us | 414 us | 0.10x |
frequencies (30k strs) |
364 us | 709 us | 1091 us | 0.51x |
curry 3-arg chain |
5 us | 10 us | 13 us | 0.54x |
groupby(len) (30k strs) |
318 us | 484 us | 494 us | 0.66x |
unique (30k ints) |
149 us | 195 us | 393 us | 0.76x |
take(1000) (30k ints) |
5 us | 5 us | 5 us | 1.00x (tie) |
drop(29k) (30k ints) |
64 us | 63 us | 65 us | 1.00x (tie) |
mapcat(reversed) (30 × 1k lists) |
95 us | 95 us | 95 us | 1.00x (tie) |
concat (30 × 1k lists) |
103 us | 101 us | 101 us | 1.01x (tie) |
cons (30k ints) |
108 us | 105 us | 104 us | 1.02x (tie) |
merge (200 dicts × 50 keys) |
112 us | 109 us | 117 us | 1.02x (tie) |
pluck('a') (10k dicts) |
85 us | 76 us | 107 us | 1.12x |
sliding_window(3) (30k ints) |
1329 us | 1133 us | 1228 us | 1.17x |
partition_all(100) (30k ints) |
117 us | 97 us | 102 us | 1.21x |
interpose (30k ints) |
276 us | 185 us | 717 us | 1.49x |
last (30k list) |
0.07 us | 0.03 us | 0.06 us | 2.65x |
frequencies (5 items) |
0.31 us | 0.09 us | 0.42 us | 3.41x |
How: eager hot functions bypass per-item C-API traffic with a GIL-held Rust-side
index (one PyObject_Hash + inline probe per element instead of two dict
lookups), CPython's own container fast paths (cached str hashes, compact-int
values, identity-first probes), vectorcall for key callbacks, and direct
PyList_GET_ITEM iteration over exact-list sources. Lazy functions toolz
composes from itertools (take, drop, concat, cons, mapcat) return
exactly those C iterators, so they tie by construction. The iterator classes
dizzle writes itself sit behind hand-written tp_iternext slot functions
instead of PyO3's generated trampoline, and pluck gets a dedicated iterator
(one PyObject_GetItem per element) instead of map(itemgetter) — that
narrowed interpose from 1.9x to ~1.4–1.5x against Cython's C class and
pluck from 1.4x to 1.1x. Inputs exporting a 1-D contiguous integer buffer
of any width and signedness (numpy int dtypes, array.array, bytes) take
zero-copy native paths in frequencies (eager count, each distinct value
boxed once, first-encounter order — keys are Python ints, dict-equal to
numpy's own scalars) and keyless unique (lazy native scan): 8–15x faster
than cytoolz at 30k elements, 11.9x at 1M np.int64 — and 3.9x faster than
np.unique(return_counts=True), since a hash count beats a sort. Keyless
topk runs a k-sized min-heap over the same buffers, boxing only the k
survivors (17x at 30k, k=10), and isdistinct scans them natively with an
early exit (1.5x on fully-distinct data, 100x+ when a duplicate appears
early). Floats stay on the iteration path: toolz's dict keeps every NaN
scalar as a distinct key, which a native value hash would silently merge.
Above ~500k elements frequencies and topk fan out across cores with
rayon (the parallel cargo feature, on by default) — frequencies first
counts one chunk alone and only fans out when the observed cardinality
says the merge will be cheap. 10M np.int64: counted in 12.9ms, 15x
faster than np.unique(return_counts=True); topk(10) in 2.4ms, 1.8x
faster than np.partition. curry
resolves inspect.signature once per chain, making chained partial
application ~2x faster than cytoolz. The remaining gaps are floors, not
algorithms: the sub-microsecond rows (last, 5-item frequencies) measure
fixed call overhead — absolute deltas of 40–220ns per call.
MIT.
