Off-By-One Errors Come From Four Places. Stop Making Them.

May 25, 202611 min read
dsaalgorithmsinterview-prepbinary-searchleetcode
Off-By-One Errors Come From Four Places. Stop Making Them.
TL;DR
  • Inclusive ranges have b - a + 1 elements, not b - a: count the fence posts, not the gaps between them.
  • Half-open intervals (i < n, not i <= n) eliminate the +1 from range arithmetic and are the standard for a reason.
  • Loop bounds: use < when the upper bound is a length, <= when it is the last valid index. Mixing them silently corrupts the loop.
  • Binary search has two valid templates (closed [left, right] and half-open [left, right)): pick one before writing and never cross them.
  • The midpoint (low + high) / 2 overflows in Java, C, C++, and Go for large arrays; always use low + (high - low) / 2.
  • Sliding window size is right - left + 1 when both pointers are inclusive, not right - left.
  • Three-case trace (n=0, n=1, n=2) catches most boundary bugs before submission.

You've traced the algorithm on paper. The logic checks out. You run it on [1, 2, 3, 4, 5] and get [1, 2, 3, 4]. Or it crashes with "index out of bounds" exactly on the last element. Or the loop exits one iteration too early and you've silently dropped a result you needed.

Off-by-one errors are not random. They come from a small set of predictable places. Once you know the root cause and recognize the four traps, you can stop writing them.

The Fence Post Problem Is the Root of All of This

Picture a straight fence, 30 meters long, with posts spaced 3 meters apart. How many posts do you need?

Ten sections. So your brain says ten posts. But you need eleven. The fence starts and ends with a post, not a gap.

"Count sections, forget the post" is the cognitive root of almost every off-by-one error. You reason about the things between the boundaries rather than the boundaries themselves.

It shows up in range arithmetic immediately. You have elements at indices 2 through 7. How many elements?

count = 7 - 2 # 5, wrong, counting sections count = 7 - 2 + 1 # 6, right, counting posts

Inclusive ranges have N - M + 1 elements, not N - M. Every time you forget the +1, you've built a fence without the last post.

Dijkstra Settled This in 1982

In a handwritten note called EWD831, Edsger Dijkstra listed all four ways to write a range of integers, say 2 through 12:

  • a) 2 ≤ i < 13 (half-open: inclusive lower, exclusive upper)
  • b) 1 < i ≤ 12 (half-open: exclusive lower, inclusive upper)
  • c) 2 ≤ i ≤ 12 (closed: both inclusive)
  • d) 1 < i < 13 (open: both exclusive)

He eliminated three of them with one test: what does an empty range look like?

With option (a), an empty range starting at 5 is 5 ≤ i < 5. Length = 5 - 5 = 0. Clean. With option (c), you'd need 5 ≤ i ≤ 4, where the upper bound goes below the lower. Ugly. Options (b) and (d) fail similar tests when the range hits zero. Option (a) wins by elimination.

Half-open intervals, inclusive lower and exclusive upper, are not a convention choice. They're the only option where empty ranges work without special-casing. Length is always hi - lo. No +1, no arithmetic guesswork.

This is why for i in range(0, n) generates indices 0 through n-1. The n in the loop condition is the LENGTH, not the last valid index. That one distinction prevents most off-by-one bugs. When you see i < n, think: the length is the wall, not the index.

The Four Traps

The good news: almost every off-by-one you will ever write falls into one of these four categories. The bad news: you already know what they are and you're still writing them.

T-shirt reading "There are only 2 hard things in Computer Science: 0. Cache Invalidation, 1. Naming Things, 7. Asynchronous Callbacks, 2. Off-by-one errors"

The list has four items. The items are numbered 0, 1, 7, and 2. The joke writes itself.

Trap 1: Loop Bounds

The most common. You use <= when you need <.

arr = [10, 20, 30, 40, 50] # Wrong: iterates n+1 times, crashes when i == len(arr) i = 0 while i <= len(arr): print(arr[i]) i += 1 # Right: iterates exactly n times i = 0 while i < len(arr): print(arr[i]) i += 1

The rule: when your upper bound is a length or count, use <. When it's the last valid index, use <=. Never apply both in the same loop without thinking hard about which you're doing.

Trap 2: The Last Element

Valid indices in a zero-indexed array of length n run from 0 to n-1. Index n does not exist.

arr = [10, 20, 30, 40, 50] last = arr[len(arr)] # IndexError: list index out of range last = arr[len(arr) - 1] # 50, correct

Obvious in isolation. Invisible when n is buried in arithmetic. arr[offset + count] instead of arr[offset + count - 1] is the same bug, just harder to see.

Trap 3: Inclusive Range Counting

Any time you compute "how many elements from index A to index B," the formula depends on whether both endpoints are included.

# Elements at indices 2, 3, 4, 5, 6, 7 → 6 elements # Inclusive range [2, 7]: remember the post length = 7 - 2 + 1 # 6, correct # Half-open range [2, 8): no +1 needed length = 8 - 2 # 6, correct # Bug: inclusive range treated as half-open length = 7 - 2 # 5, off by one

Choose half-open ranges consistently throughout your code, and this calculation is always just hi - lo. The +1 only appears when you receive inclusive endpoints from the outside world and must translate them.

Trap 4: Sliding Window Size

Sliding window problems compute window length constantly. When both left and right pointers are inclusive, the window size is right - left + 1.

left, right = 2, 5 # Covers indices 2, 3, 4, 5 → 4 elements window_size = right - left # 3, off by one window_size = right - left + 1 # 4, correct

This off-by-one corrupts "maximum window length" comparisons and fixed-window problems. The result is almost right, which makes it maddening to isolate. If your sliding window solution passes most test cases but fails edge cases near the answer, check this calculation first. See Sliding Window Technique: Turn O(n²) Loops into O(n) in One Pass for a full walkthrough of window mechanics.

Binary Search: Where This Gets Hard to Get Right

Binary search has two valid templates. The mistake is mixing them.

Template 1: Closed interval [left, right]

def search(arr, target): left, right = 0, len(arr) - 1 # right is the last valid index while left <= right: # <= because both ends are valid mid = left + (right - left) // 2 if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid - 1 # mid already checked, exclude it return -1

Template 2: Half-open interval [left, right)

def search(arr, target): left, right = 0, len(arr) # right is one past the last index while left < right: # < because right is exclusive mid = left + (right - left) // 2 if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid # not mid - 1; right is already exclusive return -1

Never mix them. Closed-interval loop condition (<=) combined with half-open right boundary (len(arr)) creates an infinite loop at the last element. Half-open condition (<) combined with inclusive right boundary (len(arr) - 1) misses that last element when it's the target.

Pick one template. Lock in the invariant before writing the first line. For a deeper treatment of how loop invariants determine every decision in binary search, see Binary Search: One Invariant to Rule the Search Space.

The Midpoint Bug That Lived in Production for Nine Years

There's a boundary arithmetic error in binary search that has nothing to do with < versus <=. In 2006, Joshua Bloch published a post revealing that the midpoint calculation in virtually every textbook:

int mid = (low + high) / 2;

is broken. When both low and high are large, near Integer.MAX_VALUE, their sum overflows to a negative number. The midpoint goes negative. The algorithm fails silently.

The bug was in Java's Arrays.binarySearch, shipped to millions of developers, for nine years before someone reported it. The same bug lived in Jon Bentley's Programming Pearls, alongside a formal proof that the algorithm was correct. The proof was right about what the algorithm does. It was wrong about the arithmetic domain.

// Wrong: overflows for large arrays (2^30+ elements) int mid = (low + high) / 2; // Right: no overflow possible int mid = low + (high - low) / 2; // Also right in Java (unsigned right shift): int mid = (low + high) >>> 1;

In Python, integers don't overflow, so this specific issue doesn't apply. In C, C++, Java, and Go, it absolutely does. Always compute binary search midpoints as left + (right - left) / 2.

This is the same energy as "fixing" code with a clever algorithm that quietly contains its own boundary error.

Tweet thread where someone "fixes" a Dutch government app's hard-coded percentage lookup table by replacing it with a binary search implementation, which itself contains subtle issues

Someone replaced 30 if-statements with O(log n) binary search. Brave. Whether the implementation is correct is left as an exercise for the reader.

The Three-Case Trace

Before submitting any solution with loops or index arithmetic, trace through three inputs:

  1. n = 0: empty input. Does the loop run zero times? Does initialization crash?
  2. n = 1: single element. Does your code return the correct answer without crashing?
  3. n = 2: two elements. Does the boundary between elements behave correctly?

Most off-by-one bugs reveal themselves at n = 1 or n = 2, because that's where the edge of your range overlaps your loop termination condition. If it passes all three and the logic clearly scales, you're done.

In an interview, narrate this explicitly. Say: "Let me trace this on a one-element array to check the boundary." That deliberate boundary-testing is one of the behaviors that gets written into feedback notes. SpaceComplexity runs voice-based mock interviews where you practice narrating this kind of check under realistic pressure, not just in retrospect when you find the bug.

For a full debugging framework that includes this technique, see Debugging in a Coding Interview: Your Approach Was Right. Your Code Has a Bug. Now What?.

When Off-By-One Gets Serious

These aren't just interview bugs. CVE-2021-3156, nicknamed Baron Samedit, was a privilege escalation vulnerability in sudo that gave any local user root access on Linux and macOS. It was introduced in 2011 and discovered in 2021. Ten years of exposure.

The mechanism: when unescaping command-line arguments that ended with a backslash, the code wrote one byte past the end of the allocated buffer, overwriting the string's null terminator. Heap buffer overflow. From there, full root privileges.

One off-by-one in argument boundary handling, a decade of exposure on virtually every Linux system. The error was structurally identical to arr[n] instead of arr[n-1]. Just in C, in a security-critical context, with different stakes.

CWE-193 is MITRE's classification entry for this class of bug if you want to understand how it maps into the broader vulnerability taxonomy.

Recap

  • Fencepost rule: inclusive range [a, b] has b - a + 1 elements, not b - a. Count the posts.
  • Half-open intervals: i < n not i <= n. The n is the LENGTH, not the last index.
  • Loop bounds: length with <, or last valid index with <=. Never mix.
  • Last element: valid indices are 0 to n-1. Index n is out of bounds.
  • Sliding window size: right - left + 1 when both pointers are inclusive.
  • Binary search: pick closed [left, right] or half-open [left, right) before writing. Never cross the templates.
  • Midpoint: always left + (right - left) / 2, not (left + right) / 2.
  • Three-case trace: test n=0, n=1, n=2 before declaring done.

For a companion guide focused specifically on binary search boundaries, see Your Binary Search Has an Off-by-One Bug. Prove Me Wrong..

Further Reading