Run it
The half-open version, and the inclusive one that stops making progress.
The invariant, stated as the thing that is true on every iteration.
The overflow idiom Python does not need, and what you would actually use.
The invariant does the work
Binary search is three lines and most people can write a version that usually works. What separates that from a correct one is being able to say what is true at the top of every iteration: if the target is present, it is in [lo, hi).
Everything follows. The loop condition is lo < hi, because an empty range means the answer is not there. When a[mid] < target, mid cannot be the answer, so lo = mid + 1 excludes it. Otherwise mid might be the answer, so hi = mid keeps it in a half-open range. Neither branch can leave the range the same size, which is termination.
The two combinations that hang
Pair an inclusive hi = len(a) - 1 with hi = mid and you have a loop that can stop making progress: when hi = lo + 1, mid equals lo, and setting hi = mid changes nothing. The editor below runs that version under a step cap so you can see it exhaust its budget rather than hanging the page.
The mirror error is lo = mid instead of lo = mid + 1, which hangs for the same reason from the other side. The rule that avoids both: the branch that keeps mid as a candidate must be the one that moves the exclusive end.
The overflow line, and why Python does not need it
You will see mid = lo + (hi - lo) // 2 in every C and Java implementation, and the reason is real: lo + hi can exceed the integer width on a large array, a bug that sat in the JDK for nine years.
Python integers are arbitrary precision, so (lo + hi) // 2 is safe here — and the editor prints both forms agreeing at 262 to show it. Knowing why the idiom exists is worth more than using it: it is a good answer to "is there anything else to say about this line?"
What to use instead
In real Python, bisect. bisect_left(a, x) is exactly this loop, written in C, and it returns the insertion point — which is strictly more useful than a boolean, because it answers "where would it go" as well as "is it there". The next question is entirely about that.
Write the loop when asked to, and say that you would reach for bisect in production. Both halves of that answer are being listened for.
What to say out loud
I use a half-open range: lo inclusive, hi exclusive, loop while lo is less than hi. If a[mid] is less than the target, lo becomes mid + 1, otherwise hi becomes mid. Both branches shrink the range strictly, so it terminates, and at the end lo is the insertion point - one comparison tells me whether the value is actually there.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
The empty array. hi = 0, the loop body never runs, and lo is 0 - which is the correct insertion point. A version starting at len(a) - 1 gets hi = -1 and needs a guard.
Targets outside the range. The answer is 0 or len(a), both of which are valid insertion points and neither of which is a valid index. The final bounds check is what separates them.
Duplicates. The plain loop returns an arbitrary one. If that matters, you want a boundary search, not this.
The follow-ups interviewers ask
"Write it recursively." Same invariant, and it costs stack depth for no benefit - log n frames rather than two variables. Worth writing if asked, and worth noting that the iterative form is what every library ships.
"What if the array is rotated?" One extra comparison per step to decide which half is sorted, then the same invariant. It is the standard escalation from this question.
"How would you test it?" Every array length from 0 to about 5, and for each one every target including one below the minimum, one above the maximum, and the gaps. That is a few dozen cases and it catches every off-by-one - which is a better answer than any single clever test.
Common wrong answers
"while lo <= hi with hi = mid." This is the combination that hangs. If hi is inclusive, the branch that keeps mid must be hi = mid - 1.
"Return mid when a[mid] == x." Correct for presence, and it makes the loop unable to answer 'first' or 'last' - which is the next question.
"Use lo + (hi - lo) // 2 because Python can overflow." Python integers cannot overflow. The idiom is right and the reason given is wrong, and interviewers notice the difference.
Recap in one screen
- The range is [lo, hi): hi is never a candidate.
- lo = mid + 1 rules mid out; hi = mid keeps it.
- The loop ends with lo at the insertion point, found or not.
- Worth trying: Change hi = mid to hi = mid - 1 in search and look for the value that is now missed. The bug is silent, which is why the invariant matters more than the shape.
- Worth trying: Return lo instead of -1 on a miss. That is bisect_left, and it is the version most real problems want.