skip to content

heapq.merge output in a nightly pipeline arrives out of order and downstream silently truncates - what went wrong?

level: seniorimportance: should knowfreq 28%

answer

  1. The name suggests more work than it does
  2. A precondition nobody validates
  3. Cheap to check at the boundary
  4. Look at the sources, not the output
  5. One ordering direction for the whole call

basics

~20 s

heapq.merge does not sort. It assumes every input iterable is already ordered, and it never checks. One unsorted source makes the merged stream go backwards, and any consumer that stops at the first out-of-order record truncates without error.

solid answer

~50 s

`heapq.merge(*iterables, key=None, reverse=False)` is a lazy interleaver, not a sorter. It pulls the head of each input, yields the smallest, and refills from whichever input it drained — which is only correct if each input is itself ascending (or descending, under `reverse=True`, and then *all* of them must be). It validates nothing and raises nothing, so an input that arrives unsorted produces output that dips backwards mid-stream. In a genome-annotation pipeline merging per-chromosome interval files, that surfaces downstream: a consumer written to bail out when coordinates go backwards stops early, and a six-hour nightly run writes a short file with no error anywhere. The fix is to assert the contract at the boundary — wrap each source in a generator that checks monotonicity — rather than to trust the producer. `key=` and `reverse=` have been available since Python 3.5.

code

python · 8 lines
python
import heapq

sorted_a = [(10, "region-a"), (30, "region-c")]
sorted_b = [(20, "region-b")]
print(list(heapq.merge(sorted_a, sorted_b, key=lambda row: row[0])))

# One unsorted input, no exception, broken output
print(list(heapq.merge([3, 1], [2])))

go deeper

for a junior

Remember the precondition: heapq.merge assumes every input is already sorted and returns a lazy generator. It is not a substitute for sorting, and it will not tell you when an input breaks the rule.

for a middle

Explain the interleaving mechanism, what key= and reverse= do and that they apply to the whole call, and why an unsorted input yields wrong output rather than an exception.

for a senior

Diagnose from the sources, not the merged stream: wrap each input in a monotonicity-checking generator that names the offending source, and explain how an unenforced precondition turns a data defect into a short output file with a zero exit status.

for a principal

Own the policy question: where in a long-running batch pipeline preconditions get asserted, who pays the per-record check, and whether the memory saving of a streaming merge is worth adopting without the boundary validation that makes its contract enforceable.

### What merge actually promises The docstring is explicit: `heapq.merge` is "similar to `sorted(itertools.chain(*iterables))` but returns a generator, does not pull the data into memory all at once, and **assumes that each of the input streams is already sorted**." That last clause is a precondition, and it is enforced by nobody. Mechanically, the function keeps one pending element per input and repeatedly yields the smallest of those, then advances that input by one. That interleaving is correct precisely when each input is monotonically non-decreasing. Feed it an input that is not, and the output is whatever the interleaving produces: ```python list(heapq.merge([3, 1], [2])) # [2, 3, 1] ``` No exception. No warning. Just a stream whose ordering silently breaks at element three. ### Why this becomes a silent truncation Downstream consumers of an ordered stream are often written to exploit the ordering: they process while coordinates advance and stop, or flush and finish, when they see a value that goes backwards — either as a deliberate sentinel or because a sorted-merge-join style step has nothing left to match. So the failure does not present as "the output was mis-ordered". It presents as "the nightly run produced a file with a third of the expected records and exited zero". Every stage reports success. The corruption entered at a producer several steps upstream that emitted one region's intervals in file order rather than coordinate order. This is the shape of failure worth naming in an interview: a precondition that is cheap to state, cheap to check, and checked nowhere, turning a data defect into a plausible-looking result rather than a crash. ### Diagnosing it The merged output is the wrong place to look, because by then you cannot tell which source misbehaved. Instrument the inputs instead. A checking generator costs one comparison per element and names the offending source: ```python def checked(source, name): previous = None for item in source: if previous is not None and item < previous: raise ValueError(f"{name} went backwards at {item!r}") previous = item yield item merged = heapq.merge(*(checked(s, n) for s, n in sources)) ``` Because `merge` is lazy, wrapping the inputs preserves the streaming behaviour: nothing is materialised, and the error fires at the first offending record rather than at the end. If the check must use the same ordering rule as the merge, apply the same `key` function inside it. ### The parameters, and their limits `key=` supplies the ordering function, exactly as `sorted` does, and — importantly — `merge` yields the **original** elements, not decorated ones. `reverse=True` merges descending inputs into a descending output. Both were added in **Python 3.5**. The limitation people trip over: `reverse` is a property of the whole call, not of an input. You cannot merge one ascending and one descending source. Reverse the offending one first, or re-sort it. Likewise there is one `key` for all inputs, so heterogeneous records must be normalised into a comparable shape before they reach the call. ### Merge versus sorting the concatenation `sorted(itertools.chain(*iterables))` is the correct-under-all-conditions alternative: it does not care about input order because it sorts. What it costs is memory — every record materialised in one list before the first result appears — and it forfeits early termination, since you cannot take the first hundred results without producing all of them. `heapq.merge` holds one pending element per input plus the small structure that orders them, so its footprint scales with the *number* of inputs rather than their length. That is what makes it the right tool for merging many large pre-sorted files, and it is exactly why the precondition matters: the whole reason to use it is that you are not going to look at all the data at once. So the answer to "what went wrong" has two halves. The proximate cause is an unsorted input. The systemic cause is that a streaming merge was adopted for its memory profile without adopting the boundary check that makes its precondition enforceable — and in a long batch run, an unenforced precondition is discovered by whoever reads the output, not by the process that produced it.

  • How does heapq.merge's memory profile differ from sorted(itertools.chain(*iterables))?
    `merge` is a generator holding one pending element per input plus the structure ordering them, so its footprint scales with the number of inputs, not their length, and a caller can stop early. `sorted(chain(...))` materialises every record in one list before yielding anything, but is immune to unsorted inputs because it does the sorting itself.
  • Can you merge one ascending source with one descending source?
    No. `reverse` applies to the whole call, and so does `key` — there is no per-input setting. Reverse the descending source first, for instance with `reversed()` on a sequence, or re-sort it, and then merge. Passing them as-is produces silently mis-ordered output, the same failure as an unsorted input.
  • Does the key function change the elements that merge yields?
    No. `key` only decides the ordering; the elements come out exactly as they went in, undecorated. That is a real convenience over a hand-rolled decorate-merge-undecorate, and it means you can order on a computed field without the consumer ever seeing it.

It is a checkout line merger, not a sorter: it repeatedly waves through whoever is at the front of each queue. If one queue was not in order to begin with, everyone still gets served, just in the wrong order, and nobody at the desk notices.

saying these in an interview costs you the question

  • Believes heapq.merge sorts its inputs
  • Expects an exception when an input is unsorted
  • Thinks merge returns a list rather than a generator
  • Says merge loads every input fully into memory
  • Assumes reverse can be set per input iterable
  • Blames the consumer instead of checking the sources

context