โ† back to journal

Python's new max-heap is worth it for anything you can't negate

2026-10-14 ยท Kit Wickham

Until 3.14, heapq only did min-heaps. If you wanted the largest item first, you either pushed -x or wrapped each item in a class with a reversed __lt__. Python 3.14 made heappush_max, heappop_max, heapify_max and friends public. I measured them against both old tricks. Against negation the gain is small, 5 to 15%. Against a wrapper class it is 2.5 to 4.5ร—, and for strings, which you can't negate, a wrapper was the only option.

The test

CPython 3.14.8. Push N random items one at a time, then pop them all, and check the output comes out sorted largest first. I timed ns per item (one push plus one pop), took the median of 7 to 21 runs, and ran each variant in its own fresh process, three rounds in shuffled order. The machine had other work on it, so call differences under about 15% ties.

itemsNheappush_maxnegatewrapper class
int1,00094107408
int100,000196218690
int1,000,0004815111,742
str1,000105โ€”398
str100,000230โ€”715
str1,000,000601โ€”2,019
(priority, name)1,000177213458
(priority, name)100,000339479882
(priority, name)1,000,0008091,2811,952

Floats came out like ints. For the tuples, "negate" means pushing (-p, name) and rebuilding the tuple on the way out. That only works because the priority is a number.

Why it comes out this way

Negating a number is one cheap C-level operation per push and per pop, so a native max-heap only saves that. With tuples it saves more, 20 to 60%, because you build a new tuple on the way in and another on the way out.

The wrapper is slow because every comparison becomes a Python method call. I counted them: a push took about 2.3 comparisons, and a pop took 9 at a thousand items and 19 at a million. That's about 21 calls to __lt__ per item at a million, so the extra 1.3 ยตs per item comes to roughly 60 ns per call. __slots__ didn't help. A plain @total_ordering class was within about 10% of the slotted one, because the cost is the call itself, not looking up the attribute.

One more thing: if all the items are there up front, heapify_max and then popping beat pushing them one at a time by 5 to 30%, because building a heap all at once is linear.

So what

If you are already on 3.14 and you negate numbers, switching is a tidy-up, not a speedup. If you have a ReverseOrder wrapper class for strings, tuples or anything else that can't be negated, delete it: the built-in is three to four times faster on strings and about 2.5ร— on tuples, and your code no longer has a class just to flip a comparison. One catch: the _max functions only reverse the comparison. There's no key=, so a heap of records still needs tuples or a class.

Method: one machine, about 100 lines of plain Python, random data from a fixed seed. These are tight-loop costs, not what a whole program would see.