Why does bisect.insort slow down as an email-digest sender's pending list grows?
answer
- Two steps, only one of them cheap
- Lists are contiguous, not linked
- Insertion moves everything after it
- Loop of inserts is quadratic
- Batch and sort once instead
basics
~20 sFinding the slot is logarithmic, but placing the item is not. A Python list stores its elements contiguously, so inserting in the middle shifts every later entry, making each insort cost time proportional to the list length.
solid answer
~40 s`bisect.insort_right` is `bisect_right` followed by `list.insert`, and only the first half is cheap. Locating the slot in 27,000 pending sends takes about 15 comparisons; the insert then moves up to 27,000 pointers with a single memmove. In absolute terms that is still only microseconds, so at this size the pattern is usually fine — what actually bites is bulk loading, where n insorts make the build O(n^2), and lists an order of magnitude larger, where each insert climbs toward hundreds of microseconds. The fixes, in order of how much they change: batch arrivals and sort once, because `list.sort` merges an already sorted run cheaply; use a heap if you only ever take the earliest item; move to a chunked sorted-container structure or the datastore's own index once ordered inserts genuinely dominate.
code
python · 18 linesimport bisect
pending = [] # send-at timestamps, kept sorted
def schedule(send_at):
bisect.insort_right(pending, send_at)
def drain_due(now):
cut = bisect.bisect_right(pending, now)
ready = pending[:cut]
del pending[:cut] # one shift, not one per item
return ready
for ts in (90, 30, 60, 120, 60):
schedule(ts)
print(pending) # [30, 60, 60, 90, 120]
print(drain_due(60)) # [30, 60, 60]
print(pending) # [90, 120]go deeper
Remember that insort is a search plus a real insertion, and that inserting into the middle of a Python list is not free because the later elements have to move. Knowing insort is not fully logarithmic is enough here.
Explain the contiguous-array layout that makes list.insert linear, and give the concrete consequence: building a sorted list with n insorts is quadratic, while appending and sorting once is not.
Demonstrate the measurement before the redesign — time at n and at 10n, profile the ingest path — and then choose deliberately among batching, a heap for pure earliest-first access, a chunked sorted container, or pushing the ordering into an indexed store.
Own the call about where ordered state lives at all: in-process sorted lists are fine while the working set is small and restarts are cheap, but a scheduler holding durable ordered work usually belongs behind an index, and that decision drives capacity, recovery and operability far more than the constant factor does.
### What insort actually does `bisect.insort_right(a, x)` is two steps: call `bisect_right` to find the index, then call `list.insert` at that index. Interviews go wrong when a candidate carries the logarithmic cost of the first step over to the whole call. A CPython list is a contiguous array of pointers to objects with some spare capacity at the end. Appending is amortised O(1) because it writes into that spare slot. Inserting at index `i` cannot: every pointer from `i` onward has to move one slot to the right first, which is a memmove of `(n - i)` machine words, plus an occasional reallocation when the spare capacity runs out. So insertion in the middle is O(n) in the length of the list, averaging n/2 moved pointers for random arrivals. For a digest sender holding 27,000 pending sends ordered by send time, that is roughly 13,500 pointers — about 100 KB — moved per insert on average. ### Order of magnitude, honestly The constant factor matters as much as the exponent here, and a senior answer says so. memmove over a contiguous block is one of the fastest things a machine does. Measured on a laptop-class CPU, an insort into the middle of a list runs roughly: - 1,000 elements: well under a microsecond - 27,000 elements: a few microseconds - 1,000,000 elements: over a hundred microseconds The growth is visibly linear, which is exactly the diagnostic: time the operation at n and at 10n. At 27,000 entries a few thousand inserts per second still costs a small fraction of a core, so "it is O(n)" is not by itself a reason to redesign. Two situations flip that. **Bulk loading.** Building the sorted list by calling insort in a loop is O(n^2) pointer moves. At a million items that is on the order of 10^12 word moves — minutes to hours, versus a fraction of a second for appending everything and calling `list.sort()` once. This is the single most common real occurrence of the problem, and it usually appears at startup or in a backfill rather than in steady state. **Scale.** Once the list reaches hundreds of thousands of entries and inserts arrive continuously, the per-insert cost stops being noise and the profile shows time inside `list.insert`. ### Diagnosing it Do not guess. Time one insort at the current size and again at ten times the size; linear growth confirms the shift rather than the comparisons. A profiler run over the ingest path shows cumulative time attributed to `insert`. If instead the time sits in comparisons, the culprit is a rich `__lt__` or an expensive key function, and the fix is a cheaper scalar key, not a new data structure. ### The remedies, cheapest first **Batch, then sort once.** If arrivals come in groups, append the whole group and call `list.sort()`. Timsort detects the existing sorted run and merges, so this is near-linear. The nuance worth stating: for a *single* new item this is slower than insort — re-sorting touches the whole list while insort only moves a tail — so batching wins in bulk and loses one item at a time. **Use a heap if the access pattern allows.** A digest sender that only ever pops the earliest send time never needs a fully ordered list; a binary heap gives logarithmic push and pop with no shifting. Reach for it when you never need range scans or ordered iteration. **Change the structure.** A chunked sorted container — a list of bounded blocks, so a shift stays inside one block — keeps ordered iteration and range queries while bounding insert cost; third-party libraries implement this. Beyond that, ordered state that must survive a restart usually belongs in a datastore whose index already solves this, and the interview answer that reaches for that first is often the right one for a scheduler. ### The adjacent traps Deletion is symmetric: `del a[i]` and `a.pop(0)` shift the tail exactly the same way, so draining from the front is also O(n) per item — take a slice of everything due and `del a[:cut]` in one operation instead of popping in a loop. Preallocation does not help; the shift is not about capacity. And a linked list is not the Python answer: node-per-element pointer chasing loses to memmove at any size that fits in cache, and it cannot be binary-searched at all.
- How would you confirm the insert shift is really the cost rather than the comparisons?Measure the operation at the current size and at ten times that size. The shift grows linearly while the search grows logarithmically, so a roughly tenfold slowdown indicts the insert. A profiler over the ingest path confirms it by attributing cumulative time to `list.insert`. If the time instead sits in comparisons, the element's `__lt__` or the key callable is the problem and a scalar key fixes it.
- When is a plain bisect-maintained sorted list still the right choice?When the list is small to moderate, reads dominate writes, and you need ordered access — range scans, nearest-neighbour lookups, ordered iteration. A contiguous array of pointers is cache-friendly and its memmove is far faster per element than any pointer-chasing structure, so the simple version wins comfortably until the list grows large or the insert rate climbs. Simplicity is a real advantage; replace it when a measurement says to.
- What is wrong with building a million-element sorted list by calling insort in a loop?It is quadratic: each of the n inserts shifts on average half the list, so the build does on the order of n^2/2 pointer moves — around 10^12 at a million items. Append everything and call `list.sort()` once instead, which is O(n log n) with a very fast implementation, then use insort only for the incremental arrivals afterwards.
- Why is popping due items one at a time from the front also a problem?`list.pop(0)` shifts every remaining element left by one, so draining k items costs O(k*n). Compute the cut point once with `bisect_right`, take `pending[:cut]` as the batch, and `del pending[:cut]` in a single operation — that is one shift for the whole drain. If the workload is purely append-at-one-end and pop-at-the-other, a double-ended queue removes the shift entirely, at the cost of no longer being bisectable.
It is a bookshelf, not a filing cabinet: finding where a volume belongs takes a glance, but making room means sliding every book to its right along by one.
saying these in an interview costs you the question
- Claims insort is O(log n) end to end
- Blames the comparisons rather than the element shift
- Builds a large sorted list by insort in a loop
- Thinks preallocating the list removes the shift
- Proposes a linked list as the Python fix
- Assumes deleting from the middle or front is free