Skip to content
← All articles

deque: the O(1) ends, maxlen, and rotate

A BFS queue or a last-N-events buffer built on a list is quadratic — fine at 100 items, unusable at 100k. Plus the maxlen annotation that --strict makes you narrow.

A Python list is a contiguous array of pointers. Appending to the right is amortised $O(1)$. Inserting at the left is $O(n)$, because every existing element has to move up one slot. The same asymmetry applies to pop(0).

That is invisible at small $n$ and fatal at large $n$: 100,000 calls to list.insert(0, i) measured 1157 ms, and the same work through deque.appendleft measured 1.04 ms — roughly 1,100x. The shape of the bug is the giveaway: it is not slow, it is quadratically slow, so it passes every test at fixture size and melts the first time production hands it a real queue.

collections.deque is a doubly-linked list of fixed-size blocks. Both ends are $O(1)$:

Operation list deque
append / pop (right) $O(1)$ $O(1)$
insert(0, x) / pop(0) $O(n)$ $O(1)$ via appendleft / popleft
d[i] (middle) $O(1)$ $O(n)$
slicing yes TypeError

The trade is random access. A deque supports indexing but walks the blocks to get there, and it does not support slicing at all — d[1:3] raises TypeError. If your algorithm indexes into the middle, you want a list.

The three things people actually use it for

A queue. BFS, work queues, anything FIFO. append on one end, popleft on the other.

A last-N buffer. deque(maxlen=n) silently discards from the opposite end when it is full. This is the entire implementation of a ring buffer:

recent: deque[Event] = deque(maxlen=100)
recent.append(event)          # never grows past 100

And deque(iterable, maxlen=n) consumes the whole iterable while retaining only the last n — the canonical way to read the tail of a stream you cannot seek in, in constant memory.

A rotation. d.rotate(k) moves the right end to the left, k steps, in $O(k)$. Round-robin scheduling, cyclic buffers, and “shift the window” all fall out of it.

💡extendleft looks like the mirror of extend. Why does click to reveal

deque([3]).extendleft([1, 2]) give deque([2, 1, 3]) and not deque([1, 2, 3])? Because extendleft is defined as a loop of appendleft. It takes 1, puts it at the front ([1, 3]), then takes 2 and puts that at the front ([2, 1, 3]). Each element goes in front of the one before it, so the input order is reversed.

This is documented and still surprises everyone, because the name suggests symmetry with extend. If you want the input order preserved on the left, reverse first:

d.extendleft(reversed(items))

The same reasoning explains rotate(1): it moves the rightmost element to the front, which is a right rotation, not a left one.

The typing detail

deque is generic (deque[float]), and maxlen is typed int | None — because an unbounded deque genuinely has no maximum. So this fails:

def headroom(buffer: deque[float]) -> int:
    return buffer.maxlen - len(buffer)
    # error: Unsupported operand types for - ("None" and "int")

The fix is a narrowing branch, and writing it forces you to decide what an unbounded buffer’s headroom means — a question the untyped version never made you answer:

def headroom(buffer: deque[float]) -> int:
    limit = buffer.maxlen
    if limit is None:
        return -1        # unbounded: no meaningful headroom
    return limit - len(buffer)
💡You need a rolling median over a stream of 10 million floats with click to reveal

a window of 500. A deque(maxlen=500) plus statistics.median is $O(w \log w)$ per step. When is that the right answer anyway? Almost always, at these numbers. $500 \log 500 \approx 4500$ comparisons per step is a few microseconds; ten million steps is well under a minute, and the code is four lines that a reviewer can verify by reading.

The asymptotically better structure — two heaps, or an order-statistic tree, giving $O(\log w)$ per step — is several dozen lines with a lazy-deletion scheme for the elements leaving the window, and every one of those lines is a place to be subtly wrong. Reach for it when the profile says this loop is the bottleneck and the window is large (tens of thousands), not before.

The part you should not compromise on is the buffer: doing the window with values[i - w : i] copies w floats per step and turns a linear pass into a quadratic one. The deque is free; the clever median is not.