Python’s heapq module has historically been centered on min-heaps: the smallest element lives at index zero. Developers who needed a max-heap commonly negated numeric priorities before pushing them and negated them again after popping.

Python 3.14 makes that workaround unnecessary for many programs. heapq now exposes a complete max-heap API: heapify_max(), heappush_max(), heappop_max(), heappushpop_max(), and heapreplace_max().

The new functions are simple, but using them well still requires understanding heap invariants, fixed-size selection, tie-breaking, and the important difference between push-pop and replace operations.

Start with the max-heap invariant

A max-heap stores its largest element at index zero. The rest of the list is arranged so every parent is greater than or equal to its children.

from heapq import heapify_max, heappop_max

scores = [18, 42, 7, 31, 25]
heapify_max(scores)

assert scores[0] == 42
assert heappop_max(scores) == 42
assert scores[0] == 31

heapify_max() transforms an existing list in place and does so in linear time. Afterward, scores is a heap, not a generally sorted list. Only the root has the simple guarantee that it is the largest element.

That distinction matters. Code should not iterate over the internal list expecting descending order.

Push and pop without negating priorities

For a growing priority queue, use heappush_max() and heappop_max() directly:

from heapq import heappop_max, heappush_max

queue = []

for priority in [4, 9, 2, 7]:
    heappush_max(queue, priority)

ordered = []
while queue:
    ordered.append(heappop_max(queue))

assert ordered == [9, 7, 4, 2]

Before Python 3.14, equivalent numeric code often stored -priority in a min-heap. Native max-heaps make the representation match the domain: a priority of 9 remains 9 in memory and in debugging output.

That improves readability and avoids arithmetic tricks that do not generalize to every comparable type.

Know the Python version boundary

All five _max functions were added in Python 3.14. Importing them therefore makes Python 3.14 part of the runtime contract.

For an application that already requires Python 3.14, prefer the direct API. A library supporting older releases needs an explicit compatibility strategy instead of unconditionally importing these names.

A small compatibility layer can keep the decision localized, but do not maintain two implementations unless the supported-version policy actually requires it. Version compatibility is easier to reason about when it is visible in packaging metadata and tests.

Prefer tuples for priority plus payload

Real queues usually carry an object as well as a priority. Tuples work because heap comparisons are lexicographic:

from heapq import heappop_max, heappush_max

queue = []

heappush_max(queue, (100, "critical"))
heappush_max(queue, (20, "background"))
heappush_max(queue, (60, "interactive"))

priority, name = heappop_max(queue)
assert (priority, name) == (100, "critical")

But the second tuple field participates when priorities tie. That may be accidental. If payload objects are not mutually comparable, tied priorities can even raise TypeError.

Use a monotonic sequence number to make ties explicit:

from heapq import heappop_max, heappush_max
from itertools import count

sequence = count()
queue = []


def enqueue(priority, task):
    # Negate only the sequence so earlier insertions win equal-priority ties.
    heappush_max(queue, (priority, -next(sequence), task))


def dequeue():
    priority, _, task = heappop_max(queue)
    return priority, task

With a max-heap, larger tuple fields win. Negating the increasing sequence number makes the earlier insertion have the larger tie-break value (0, then -1, then -2). The priority itself remains natural and unmodified.

heappushpop_max() keeps the smaller side

The combined operations deserve special attention because their names look similar while their selection behavior differs.

heappushpop_max(heap, item) first considers the new item and then returns the largest value. The smaller values remain in the heap.

That makes it useful for maintaining the smallest k values seen in a stream:

from heapq import heappush_max, heappushpop_max


def smallest_k(values, k):
    if k < 0:
        raise ValueError("k must be non-negative")
    if k == 0:
        return []

    heap = []
    for value in values:
        if len(heap) < k:
            heappush_max(heap, value)
        else:
            heappushpop_max(heap, value)

    return sorted(heap)


assert smallest_k([9, 1, 7, 3, 2, 8], 3) == [1, 2, 3]

Once the heap reaches size k, its root is the largest value among the retained candidates. A new smaller value displaces that root. A new larger value is immediately returned and the retained set stays unchanged.

This is the mirror image of the common min-heap technique for retaining the largest k values.

heapreplace_max() always removes an existing heap item

heapreplace_max(heap, item) has a different contract. It pops the current largest heap element and then inserts item, keeping the heap size unchanged.

from heapq import heapify_max, heapreplace_max

heap = [10, 8, 4]
heapify_max(heap)

removed = heapreplace_max(heap, 100)

assert removed == 10
assert heap[0] == 100

Notice that the new value 100 remains in the heap even though it is larger than the value returned.

By contrast, heappushpop_max(heap, 100) would return 100 and leave the original heap unchanged.

Choose between the two based on the data contract:

  • use heappushpop_max() when the incoming item participates in deciding which largest item to discard;
  • use heapreplace_max() when an existing item must be removed and replaced regardless of the new value.

Both combined operations are more direct than spelling the corresponding push and pop as two independent heap operations.

Empty heaps are a real boundary

heappop_max() and heapreplace_max() raise IndexError when the heap is empty. If emptiness is expected in your domain, model it deliberately.

from heapq import heappop_max


def try_pop(heap):
    if not heap:
        return None
    return heappop_max(heap)

Returning None is appropriate only when None cannot also be a valid queued value or when the API documents that ambiguity. In many systems, allowing IndexError or raising a domain-specific exception is clearer.

Avoid catching a broad Exception around heap operations. Comparison failures and programming errors should not be silently reclassified as an empty queue.

Do not mutate heap entries arbitrarily

A heap’s correctness depends on its invariant. Changing a priority in place can violate that invariant:

# Dangerous: changing an arbitrary entry does not restore heap ordering.
queue[3] = (999, 0, "changed")

If priorities need to change frequently, a common design is lazy invalidation: mark an old entry as stale, push a new entry, and skip stale entries when popping. Another option is a data structure designed specifically for mutable priorities.

Calling heapify_max() after every update restores the invariant, but rebuilding the whole heap can defeat the efficiency that motivated using a priority queue.

Native max-heaps remove a common negation trap

The old negation pattern works well for ordinary integers and floats:

from heapq import heappush

heap = []
heappush(heap, -50)

But it mixes queue direction with value transformation. That can obscure logs, complicate compound keys, and force every boundary to remember whether a value is currently negated.

It also assumes unary negation is meaningful for the priority type. A comparable domain object may have a natural ordering without defining negation at all.

Native max-heap operations express direction in the operation rather than encoding it into the data.

When migrating existing code, change the representation and operations together. Do not feed previously negated stored priorities into a native max-heap and expect the same ordering; that would invert the meaning twice.

Comparison still uses <

Python’s heap implementation uses the less-than operator for comparisons, including max-heaps. Custom values therefore need a coherent ordering.

For application records, it is usually safer to keep comparison keys separate from payloads rather than making an entire mutable domain object orderable just so it can live in a heap.

from dataclasses import dataclass
from heapq import heappush_max


@dataclass(frozen=True)
class Job:
    name: str


heap = []
heappush_max(heap, (50, 0, Job("index")))

If the first fields always distinguish entries, the payload does not need to participate in comparison. A unique sequence field is especially useful for guaranteeing that property.

Also be cautious with floating-point NaN. Because NaN does not obey ordinary total-order expectations, it is a poor priority value unless your application normalizes or rejects it first.

A heap is not a thread-safe work queue

heapq provides algorithms over ordinary Python lists. It does not provide synchronization, blocking reads, shutdown semantics, or worker coordination.

For concurrent producer-consumer code, use an abstraction that owns those concerns or protect shared heap state with appropriate synchronization. The fact that an individual list operation happens to execute under a particular interpreter implementation is not an application-level concurrency contract.

Keep algorithm choice separate from concurrency policy.

Use nlargest() and nsmallest() for one-off selection

Not every top-k problem needs manually maintained heap state. heapq.nlargest() and heapq.nsmallest() already express one-off selection clearly:

from heapq import nlargest

scores = [31, 12, 88, 45, 63]
assert nlargest(3, scores) == [88, 63, 45]

The Python documentation notes that these functions perform best when n is relatively small. For larger n, sorting can be more efficient, and for n == 1, min() or max() is generally preferable.

Use explicit heap maintenance when values arrive incrementally, when repeated queue operations are required, or when retaining bounded state during streaming is part of the design.

Test invariants and boundaries

Tests for max-heap code should go beyond a single descending example. Cover:

  • empty input;
  • one element;
  • duplicate priorities;
  • already ascending and descending input;
  • tied priorities with non-comparable payloads;
  • the exact behavior of heappushpop_max() versus heapreplace_max();
  • fixed-size heaps with incoming values below, equal to, and above the root;
  • invalid k values in bounded-selection helpers;
  • custom comparison objects if the application uses them;
  • migration cases if old code stored negated priorities;
  • the minimum supported Python version.

Property-based tests can also be useful. For arbitrary comparable inputs, repeatedly popping a max-heap should produce the same values as sorting the input in descending order.

For a bounded smallest_k() implementation, compare its result with sorted(values)[:k] across generated inputs.

Prefer semantics that match the domain

Python 3.14’s max-heap API is not a new data structure. It is a direct interface to the other half of an algorithm Python developers were already using.

That directness is valuable. Priorities stay in their natural representation. Debugging output reflects domain values. Compound comparison keys become easier to reason about. Streaming smallest-k algorithms no longer need a negation convention.

The key is to preserve the abstraction’s boundaries: a heap is only partially ordered, ties need an explicit policy, combined push-pop and replace operations have different semantics, arbitrary mutation can break the invariant, and heapq itself does not solve concurrency.

When those rules are clear, the Python 3.14 max-heap functions make priority-oriented code simpler without hiding the algorithm that gives it its performance.