skip to content

Why can slices.BinarySearchFunc return a confident wrong hit, and how do you catch it?

level: seniorimportance: should knowfreq 34%

answer

  1. the precondition is a promise, not a check
  2. sorted by which key?
  3. no panic, just a wrong answer
  4. one comparison, two call sites
  5. let a linear scan be the oracle

basics

~20 s

Binary search assumes the slice is sorted by the comparison it is given. Data ordered by a different key makes it halve the wrong way and return a plausible index with no error. Assert sortedness, and test against a linear scan.

solid answer

~50 s

`slices.BinarySearchFunc` requires the slice to be sorted in increasing order **as defined by the comparison you pass it**, and it verifies nothing. When a table sorted by name for display is later searched by id, every halving decision rests on an ordering the data does not have: the search walks into the wrong half and returns whatever is there - usually `(i, false)` for a key that is present, occasionally a match on the wrong record. No panic, no error, so it surfaces as a wrong answer downstream. Three defences: derive the sort comparison and the search comparison from one key function so they cannot drift; assert `slices.IsSortedFunc` once where the sorted index is built; and in tests, run the search against a linear scan over the same data, requiring identical answers for present and absent keys alike.

code

go · 7 lines
go
func byID(a, b record) int { return strings.Compare(a.ID, b.ID) }

slices.SortFunc(recs, byID)

i, ok := slices.BinarySearchFunc(recs, target, func(r record, id string) int {
	return strings.Compare(r.ID, id)
})

go deeper

for a junior

Remember the one rule that matters here: a binary search only works on data already sorted by the same key you are searching on, and Go will not tell you when that is untrue.

for a middle

Explain what actually happens on unsorted input - the halving decisions go the wrong way, so the result is undefined rather than an error - and where slices.IsSortedFunc belongs.

for a senior

Show the whole defence: one shared comparison so sort and search cannot drift, an O(n) assertion at construction, and a differential test against a linear scan covering absent keys and both extremes.

for a principal

Own the rule that an optimisation ships with the evidence that behaviour is unchanged. A fast path that adds an unenforced precondition is a liability the next maintainer inherits without knowing it.

## The shape of the bug A small CLI answers lookups against a local on-disk index. At startup it loads the index's records into a slice, and a lookup does a binary search by record id. The records were sorted - but by **name**, because an earlier feature printed them in name order, and nobody re-sorted them when the id lookup was added. The binary search still runs. It still returns in about log2(n) steps. It just answers a question about a slice that does not have the ordering it assumed: - most present ids come back as **not found**, at an insertion index that is meaningless; - some ids come back **found at the wrong index**, because the comparison happened to hit an equal key on a path it should never have taken; - which ids fail depends on the data layout, so the failure is data-dependent, not deterministic across releases. No panic, no error return, nothing in a log. The precondition of the algorithm is a promise the caller makes and the library takes on trust. ## Why the library cannot help Checking sortedness costs O(n), which would defeat the entire purpose of an O(log n) search - a linear scan for the element would be no more expensive. So the documentation states the requirement ("the slice must be sorted in increasing order, where increasing is defined by cmp") and stops there. The result on unsorted input is not "an error", it is simply undefined. Note the second half of that sentence, because it is the part that bites: **sorted by which comparison?** A slice can be perfectly sorted and still be the wrong input if the search's comparison orders it differently. Sorted-by-name is not sorted-by-id. It is also not sorted-by-id-case-insensitively, and it is not sorted-by-id if one path compares numerically and another lexically - `"10"` sorts before `"9"` as a string. ## Defence 1: one comparison, two call sites The root cause is two orderings that were allowed to drift apart. Fix it structurally: define one key function or one comparison, and make both the sort and the search go through it. If the sorted index is a type - a struct wrapping the slice, with a `Lookup` method - the ordering has exactly one place to live, and the constructor is the only code allowed to sort. ```go func byID(a, b record) int { return strings.Compare(a.ID, b.ID) } func byIDTarget(r record, id string) int { return strings.Compare(r.ID, id) } ``` Both derive from the same field and the same comparison; a reviewer can see at a glance that they agree. ## Defence 2: assert once, where the index is built The O(n) check is unacceptable per lookup and completely acceptable once, at construction: ```go if !slices.IsSortedFunc(recs, byID) { return nil, fmt.Errorf("record index is not sorted by ID") } ``` That converts a silent wrong answer into a loud startup failure - the trade every on-call engineer wants. For a big index where even one pass is costly, the same check can be compiled out behind a build tag or run only in tests. ## Defence 3: the differential test The strongest guard is a test that computes the same answer two ways and compares. A linear scan is obviously correct, so make it the oracle: ```go for _, want := range recs { got, ok := slices.BinarySearchFunc(recs, want.ID, byIDTarget) if !ok || recs[got].ID != want.ID { t.Fatalf("binary search missed %q", want.ID) } } ``` And crucially, test **absent** keys too - ids that sort before everything, after everything, and in the gaps - asserting that the search reports not-found at the same position a linear scan would compute. A test suite that only looks up keys it just inserted, in a slice of five elements, passes happily on unsorted data, because a five-element search inspects almost everything anyway. Feed it a few thousand records with realistic ids and the differential check catches the ordering mismatch on the first run. ## The context this usually arises in This is the classic failure of an optimisation: someone replaces a linear `slices.Index` with a binary search to make lookups faster **without changing behaviour**. Making it faster is easy; the "without changing behaviour" half is the part that needs evidence, and the evidence is the differential test plus the sortedness assertion. Add both in the same change as the optimisation, not afterwards - and keep the linear implementation around in the test as the oracle, since it is now the only thing that can prove the fast path right.

  • Why does neither slices.BinarySearchFunc nor slices.BinarySearch verify that the input is sorted?
    Verifying costs a full O(n) pass, which is more work than the O(log n) search it would protect - at that price you could simply scan for the element. The library states the requirement and leaves enforcement to the caller, which is why the useful place for a check is once, where the sorted structure is built, rather than on every lookup.
  • Your lookup table is sorted by name and searched by id. What does the bug look like in production?
    Data-dependent misses. Most present ids report not found, a few report a match at a record with a different id, and which ones do depends on where the values landed in name order - so it can change when the underlying data changes and looks like flakiness. Small test fixtures hide it, because a search over a handful of elements inspects nearly all of them.
  • What makes a differential test against a linear scan a strong guard here?
    The linear scan makes no assumption about ordering, so it is correct by construction and can serve as the oracle. Running both over a few thousand realistic records and requiring identical answers - for keys present, keys absent, keys sorting before the first and after the last - fails immediately on an ordering mismatch that a spot-check of freshly inserted keys would never expose.
  • You are replacing a linear lookup with a binary search purely for speed. What ships in the same change?
    The sortedness assertion at construction, the differential test with the linear implementation kept as the oracle, and a benchmark showing the win is real at production data sizes. Below a few dozen elements the linear scan often wins anyway and carries no precondition for a future maintainer to violate - so the measurement decides whether the change is worth its new obligation.

saying these in an interview costs you the question

  • Expects an error or a panic when the input is not sorted
  • Sorts by one key and searches by another
  • Believes a false result reliably means the key is absent
  • Tests only keys that were just inserted
  • Checks sortedness on every lookup, cancelling the speedup