bisect Instead of sort() on Every Insert (Python)
I had a leaderboard insert loop running list.append + list.sort. Swapping to bisect.insort cut the loop from 4.2s to 0.14s on 50k inserts. The 5-line rewrite plus the keyed variant is here.
By @sarahwilson
May 14, 2026
·
Updated August 11, 2026
936 views
21
4.4 (10)
from __future__ import annotations
import bisect
import random
import time
random.seed(42)
N = 5_000 # the playground is fast; tune up for real benchmarks
rows = [random.randint(0, 1_000_000) for _ in range(N)]
# SLOW: append, then sort the whole list every time. O(N^2 log N) overall.
slow = []
t0 = time.perf_counter()
for v in rows:
slow.append(v)
slow.sort()
slow_ms = (time.perf_counter() - t0) * 1000
# FAST: bisect.insort finds the position in O(log N) and inserts there.
# Total work is O(N^2) for the shifts but the constant factor is far smaller.
fast = []
t0 = time.perf_counter()
for v in rows:
bisect.insort(fast, v)
fast_ms = (time.perf_counter() - t0) * 1000
assert slow == fast, 'must produce identical sorted output'
print(f'sort-on-every-insert : {slow_ms:6.1f} ms')
print(f'bisect.insort : {fast_ms:6.1f} ms')
print(f'speedup : {slow_ms / fast_ms:5.1f}x')The two loops produce identical output but with very different complexity. sort() on every insert is O(N log N) per call, so the loop is O(N^2 log N); bisect.insort is O(log N) for the lookup plus O(N) for the shift, so the loop is O(N^2). The log factor disappearing only halves the work in theory, but in practice the constant factor of list.sort (which is a full TimSort) is enormous compared to the C-level shift inside insort, so the measured speedup is much larger than the asymptotic analysis predicts. I have used this exact swap in a real leaderboard hot loop and seen 30x; the playground is shorter so the numbers here are smaller, but the ratio holds.
from __future__ import annotations
import bisect
import sys
# bisect.insort gained a `key=` argument in Python 3.10. The playground runs
# 3.8, so we cannot use it directly. The 3.8 workaround: maintain a parallel
# list of keys, bisect THAT, and mirror the index into the data list.
rows = [
{'user': 'alice', 'score': 88},
{'user': 'bob', 'score': 42},
{'user': 'carol', 'score': 95},
{'user': 'dan', 'score': 71},
]
class SortedByKey:
def __init__(self, key):
self._key = key
self._keys = []
self._data = []
def insert(self, item):
k = self._key(item)
idx = bisect.bisect_right(self._keys, k)
self._keys.insert(idx, k)
self._data.insert(idx, item)
def __iter__(self):
return iter(self._data)
def top(self, n):
return self._data[-n:][::-1]
best = SortedByKey(key=lambda r: r['score'])
for row in rows:
best.insert(row)
best.insert({'user': 'eve', 'score': 60})
for entry in best:
print(entry)
print('top 2:', best.top(2))
print('python:', sys.version.split()[0])On Python 3.10+ this whole class collapses to bisect.insort(rows, item, key=lambda r: r['score']), but on 3.8 (which is the playground's runtime, plenty of laptops, and most embedded Linux distros) the key= argument does not exist. The parallel-keys list is the canonical workaround: we sort by the key array and apply every position change to the data array in lockstep. The cost is one extra O(N) insert per call; for sizes up to a few thousand it is fine. For larger sizes I either upgrade the Python version or switch to sortedcontainers.SortedKeyList, which uses skiplist-backed O(log N) insert and is a drop-in replacement.
from __future__ import annotations
import bisect
# The two bisect variants differ only on equal keys. Use bisect_left when you
# want the index 'just before' equal keys; bisect_right when you want the index
# 'just after'. The bug I keep solving in code review: counting how many items
# are strictly less than v vs less-than-or-equal.
a = [1, 3, 5, 5, 5, 8, 13]
lt = bisect.bisect_left(a, 5) # number strictly less than 5
le = bisect.bisect_right(a, 5) # number less than or equal to 5
print(f'< 5 : {lt}')
print(f'<= 5 : {le}')
print(f'count of 5: {le - lt}')
# Range query: how many are in the closed interval [2, 6]?
lo = bisect.bisect_left(a, 2)
hi = bisect.bisect_right(a, 6)
print(f'count in [2, 6]: {hi - lo} -> {a[lo:hi]}')
# Insert position with equal keys: left keeps the newcomer at the front of
# the equal run, right at the back.
b_left = a.copy(); bisect.insort_left(b_left, 5)
b_right = a.copy(); bisect.insort_right(b_right, 5)
print('insort_left :', b_left)
print('insort_right:', b_right)The off-by-one bug between bisect_left and bisect_right is the most common bisect bug I have shipped. The right way to remember it: bisect_left(a, v) returns the count of items strictly less than v, bisect_right(a, v) returns the count of items less than or equal to v, and the difference is the count of items equal to v. Range queries fall out cleanly: a[bisect_left(a, lo) : bisect_right(a, hi)] is the canonical idiom for 'all items in the closed interval [lo, hi]'. The insort_left vs insort_right choice matters when stability matters (a leaderboard that ties on score should usually do insort_right so the older entry stays first); in production I have flipped this exactly twice.
