Loading content...
There are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors.
This classic joke captures a fundamental truth: off-by-one errors (commonly abbreviated as OBOE or "fencepost errors") are so pervasive that even experienced programmers make them regularly. They account for more algorithmic bugs than any other single category.
Why Off-by-One Errors Are Ubiquitous
Off-by-one errors stem from a fundamental mismatch between how humans naturally think about counting and how computers index arrays:
This cognitive dissonance creates endless opportunities for mistakes. Add to this the complexity of inclusive vs. exclusive bounds, and you have a recipe for bugs that even experts make daily.
This page will make you an expert at recognizing, preventing, and fixing off-by-one errors. You'll learn the specific patterns where OBOE occurs, develop mental models that make correct indexing automatic, and build checklists that catch these bugs before they compile. The goal: making off-by-one errors rare in your code, not common.
The name "fencepost error" comes from a classic puzzle:
If you build a fence 100 feet long with posts every 10 feet, how many posts do you need?
The intuitive answer is 10 posts (100 ÷ 10). But the correct answer is 11 posts—you need a post at both the beginning AND the end of the fence.
The General Principle
This illustrates a fundamental truth: the number of segments is always one less than the number of endpoints.
Nearly every off-by-one error can be traced back to confusing the relationship between items/endpoints and the spaces/segments between them.
| Concept | Number of Items | Number of Gaps/Segments | Relationship |
|---|---|---|---|
| Array of n elements | n elements | n-1 gaps between elements | elements = gaps + 1 |
| Loop iterations from 0 to n-1 | n iterations | n-1 'steps' between iter. | iterations = steps + 1 |
| Subarray from i to j (inclusive) | j - i + 1 elements | j - i gaps | elements = length + 1 |
| Subarray from i to j (exclusive end) | j - i elements | j - i - 1 gaps | elements = end - start |
| String of length n | n characters | n-1 between-char positions | chars = positions + 1 |
Whenever you calculate array sizes, loop bounds, or indices, pause and ask: 'Am I counting fenceposts or fence segments?' If you're counting things that have a boundary at both ends, you need +1. If you're counting spaces between things, you don't.
The most common location for off-by-one errors is in loop bounds. Understanding loop invariants and bound conventions is essential.
The Three Questions for Every Loop
Before writing any loop, explicitly answer:
If you can't answer all three instantly, you're at risk for an OBOE.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
# === PATTERN 1: Standard array iteration === # CORRECT: Iterate over all n elements (indices 0 through n-1)def process_all_elements(arr): n = len(arr) for i in range(n): # i = 0, 1, 2, ..., n-1 (n iterations) process(arr[i]) # EQUIVALENT: Explicit start and enddef process_all_elements_explicit(arr): n = len(arr) for i in range(0, n): # [0, n) - n is EXCLUSIVE process(arr[i]) # BUGGY: Using <= with rangedef process_all_elements_buggy(arr): n = len(arr) # for i in range(n + 1): # Would access arr[n] - INDEX ERROR! # The above line would iterate n+1 times, accessing one past the end # === PATTERN 2: Iterating pairs of adjacent elements === # CORRECT: n elements have n-1 adjacent pairsdef process_adjacent_pairs(arr): n = len(arr) for i in range(n - 1): # i = 0, 1, ..., n-2 (n-1 pairs) process(arr[i], arr[i + 1]) # BUGGY: Using range(n) for pairsdef process_adjacent_pairs_buggy(arr): n = len(arr) for i in range(n): # BUG: When i = n-1, arr[i+1] = arr[n] - INDEX ERROR! process(arr[i], arr[i + 1]) # === PATTERN 3: Iterating subarrays or ranges === # CORRECT: Sum elements from index 'start' to index 'end' (inclusive)def sum_range_inclusive(arr, start, end): total = 0 for i in range(start, end + 1): # +1 because 'end' is inclusive total += arr[i] return total # Elements counted: end - start + 1 # CORRECT: Sum elements from index 'start' to index 'end' (end exclusive)def sum_range_exclusive(arr, start, end): total = 0 for i in range(start, end): # No +1, 'end' is already exclusive total += arr[i] return total # Elements counted: end - start # === PATTERN 4: Reverse iteration === # CORRECT: Iterate backwards from n-1 to 0 (inclusive)def reverse_iterate(arr): n = len(arr) for i in range(n - 1, -1, -1): # i = n-1, n-2, ..., 1, 0 process(arr[i]) # The second argument (-1) is EXCLUSIVE, so we stop after 0 # BUGGY: Off-by-one in reversedef reverse_iterate_buggy(arr): n = len(arr) # for i in range(n - 1, 0, -1): # BUG: Stops at 1, misses index 0! # for i in range(n, -1, -1): # BUG: Starts at arr[n] - INDEX ERROR! # === PATTERN 5: Nested loops for pairs === # CORRECT: All unique pairs (i, j) where i < jdef all_unique_pairs(arr): n = len(arr) for i in range(n): for j in range(i + 1, n): # j starts at i+1 to ensure i < j process(arr[i], arr[j]) # Number of pairs: n*(n-1)/2 # BUGGY: Including i == jdef all_unique_pairs_buggy(arr): n = len(arr) for i in range(n): for j in range(i, n): # BUG: Includes (i, i) pairs when i == j process(arr[i], arr[j])Binary search is infamous for off-by-one errors. Even Donald Knuth famously noted that while the first binary search was published in 1946, the first bug-free published version didn't appear until 1962—16 years later!
The difficulty stems from three independent decisions that must be consistent:
right set to n (exclusive) or n-1 (inclusive)?left < right or left <= right?left = mid + 1 or left = mid? Similar for right.The Two Valid Paradigms
There are two internally consistent approaches to binary search. Mixing elements from both paradigms causes bugs.
Inclusive on Both Ends: The search space is [left, right], meaning both endpoints are candidates.
Key Properties:
right = n - 1left <= right (continue while at least one candidate exists)left = mid + 1 and right = mid - 1 exclude mid12345678910111213141516171819202122232425
def binary_search_inclusive(arr, target): """ Search for target in sorted array using [left, right] inclusive bounds. Returns index if found, -1 otherwise. """ if not arr: return -1 left = 0 right = len(arr) - 1 # Inclusive: right is a valid index while left <= right: # Continue while [left, right] is non-empty mid = left + (right - left) // 2 # Avoids overflow if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 # Exclude mid, search [mid+1, right] else: right = mid - 1 # Exclude mid, search [left, mid-1] return -1 # Not found # Invariant: target (if it exists) is always in [left, right]# Termination: when left > right, the search space is emptyThe most common binary search bug is mixing conventions: using right = n (exclusive) but then left <= right (inclusive check), or using right = n - 1 (inclusive) but then right = mid (which only excludes mid if right is exclusive). Pick one paradigm and use it consistently.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# === COMMON BINARY SEARCH VARIANTS === def lower_bound(arr, target): """ Find the leftmost position where target could be inserted. Equivalent to: first index where arr[i] >= target. Uses [left, right) convention. """ left, right = 0, len(arr) while left < right: mid = left + (right - left) // 2 if arr[mid] < target: left = mid + 1 else: right = mid # arr[mid] >= target, mid is a candidate return left # left == right, this is the insertion point def upper_bound(arr, target): """ Find the rightmost position where target could be inserted. Equivalent to: first index where arr[i] > target. Uses [left, right) convention. """ left, right = 0, len(arr) while left < right: mid = left + (right - left) // 2 if arr[mid] <= target: # Note: <= not < left = mid + 1 else: right = mid return left def find_first_occurrence(arr, target): """Find the first (leftmost) occurrence of target.""" pos = lower_bound(arr, target) if pos < len(arr) and arr[pos] == target: return pos return -1 def find_last_occurrence(arr, target): """Find the last (rightmost) occurrence of target.""" pos = upper_bound(arr, target) - 1 # upper_bound finds first GREATER if pos >= 0 and arr[pos] == target: return pos return -1 def count_occurrences(arr, target): """Count how many times target appears in sorted array.""" return upper_bound(arr, target) - lower_bound(arr, target)Working with substrings and subarrays is a constant source of off-by-one errors because different languages use different conventions, and even within a language, different functions may behave differently.
The Core Confusion: Length vs. Index
When you have a subarray from index start to index end:
end is inclusive, the length is end - start + 1end is exclusive, the length is end - startMost languages use exclusive end bounds for slicing (Python, JavaScript, Go), but some common operations are inclusive (SQL BETWEEN, some string functions).
| Language/Context | Syntax | Convention | Example (get 'bcd') |
|---|---|---|---|
| Python slice | str[start:end] | End exclusive | 'abcde'[1:4] → 'bcd' |
| JavaScript slice | str.slice(start, end) | End exclusive | 'abcde'.slice(1, 4) → 'bcd' |
| JavaScript substring | str.substring(start, end) | End exclusive | 'abcde'.substring(1, 4) → 'bcd' |
| Java substring | str.substring(start, end) | End exclusive | "abcde".substring(1, 4) → "bcd" |
| C++ substr | str.substr(start, length) | Length-based | "abcde".substr(1, 3) → "bcd" |
| SQL BETWEEN | BETWEEN a AND b | Both inclusive | Includes both endpoints |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
# === CALCULATING SUBARRAY LENGTHS === def subarray_length_inclusive(start: int, end: int) -> int: """Length when end is inclusive: [start, end]""" return end - start + 1 # Example: [2, 5] includes indices 2, 3, 4, 5 → length 4 def subarray_length_exclusive(start: int, end: int) -> int: """Length when end is exclusive: [start, end)""" return end - start # Example: [2, 5) includes indices 2, 3, 4 → length 3 # === CONVERTING BETWEEN LENGTH AND END INDEX === def inclusive_end_from_length(start: int, length: int) -> int: """Given start and length, find inclusive end index.""" return start + length - 1 # -1 because start counts as one element # Example: start=2, length=4 → end=5 (indices 2, 3, 4, 5) def exclusive_end_from_length(start: int, length: int) -> int: """Given start and length, find exclusive end index.""" return start + length # No -1 because end is exclusive # Example: start=2, length=4 → end=6 (indices 2, 3, 4, 5) # === COMMON BUGGY PATTERNS === # BUGGY: Wrong sliding window boundsdef max_sum_window_buggy(arr, k): """Maximum sum of any k consecutive elements.""" n = len(arr) if n < k: return None # Calculate first window window_sum = sum(arr[:k]) max_sum = window_sum # BUG: Wrong range for sliding window for i in range(k, n + 1): # BUG: n+1 will cause index error window_sum = window_sum - arr[i - k] + arr[i] # arr[n] doesn't exist! max_sum = max(max_sum, window_sum) return max_sum # CORRECT: Proper sliding window boundsdef max_sum_window_correct(arr, k): """Maximum sum of any k consecutive elements.""" n = len(arr) if n < k: return None window_sum = sum(arr[:k]) max_sum = window_sum # CORRECT: i ranges from k to n-1 (inclusive) for i in range(k, n): # i is the index of the NEW element entering window window_sum = window_sum - arr[i - k] + arr[i] # Window is now [i-k+1, i] inclusive max_sum = max(max_sum, window_sum) return max_sum # Total windows checked: n - k + 1 # BUGGY: Checking all substringsdef has_palindrome_buggy(s, length): """Check if s has any palindromic substring of given length.""" n = len(s) # BUG: Wrong bounds for substring extraction for i in range(n - length): # BUG: Misses last valid starting position! substring = s[i:i + length] if substring == substring[::-1]: return True return False # For s = "abcba" and length = 5, this would miss the entire string! # CORRECT: Proper substring iterationdef has_palindrome_correct(s, length): """Check if s has any palindromic substring of given length.""" n = len(s) # CORRECT: i can go from 0 to n - length (inclusive) for i in range(n - length + 1): # +1 includes the last valid start substring = s[i:i + length] # [i, i+length) gives 'length' characters if substring == substring[::-1]: return True return FalseWhen unsure about subarray/substring bounds, count on your fingers. For string 'abcde' (length 5), valid starting positions for substrings of length 3 are: 0 (abc), 1 (bcd), 2 (cde). That's 3 positions = 5 - 3 + 1 = n - length + 1. This formula never fails.
Different algorithms have unique boundary conditions that are easy to get wrong. Let's examine common patterns in frequently-used algorithms.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
# === TWO POINTERS: Meeting in the Middle === # BUGGY: Checking pairs in-place (e.g., palindrome check)def is_palindrome_buggy(s): left = 0 right = len(s) # BUG: Should be len(s) - 1 while left < right: if s[left] != s[right]: # IndexError: s[len(s)] doesn't exist return False left += 1 right -= 1 return True # CORRECT: Proper initialization for two pointers meeting in middledef is_palindrome_correct(s): left = 0 right = len(s) - 1 # CORRECT: Last valid index while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True # === MERGE SORT: Merging Two Halves === # BUGGY: Wrong midpoint calculationdef merge_sort_buggy(arr, left, right): if left >= right: return # BUG: This can cause infinite recursion or wrong split mid = (left + right + 1) // 2 # Off by one for some inputs merge_sort_buggy(arr, left, mid) # May include mid in left half merge_sort_buggy(arr, mid + 1, right) # And also start right half at mid+1 merge(arr, left, mid, right) # CORRECT: Standard midpoint in merge sortdef merge_sort_correct(arr, left, right): """Sort arr[left:right+1] in place.""" if left >= right: return mid = left + (right - left) // 2 # CORRECT: mid is in left half merge_sort_correct(arr, left, mid) # Sort [left, mid] merge_sort_correct(arr, mid + 1, right) # Sort [mid+1, right] merge(arr, left, mid, right) # === QUICK SORT: Partition Boundaries === def partition(arr, low, high): """Lomuto partition scheme - partition around last element.""" pivot = arr[high] i = low - 1 # i is the index of last element <= pivot (initially before start) for j in range(low, high): # Note: high is not included (it's the pivot) if arr[j] <= pivot: i += 1 arr[i], arr[j] = arr[j], arr[i] # Place pivot in its correct position arr[i + 1], arr[high] = arr[high], arr[i + 1] return i + 1 # === HEAP OPERATIONS: Parent-Child Index Relationships === # For 0-indexed arrays:def parent(i): return (i - 1) // 2 # Not i // 2 (that's for 1-indexed) def left_child(i): return 2 * i + 1 # Not 2 * i (that's for 1-indexed) def right_child(i): return 2 * i + 2 # Not 2 * i + 1 (that's for 1-indexed) # For 1-indexed arrays:def parent_1indexed(i): return i // 2 def left_child_1indexed(i): return 2 * i def right_child_1indexed(i): return 2 * i + 1 # BUGGY: Heapify with wrong child indicesdef heapify_buggy(arr, n, i): """Heapify subtree rooted at index i (max-heap).""" largest = i left = 2 * i # BUG: Wrong for 0-indexed right = 2 * i + 1 # BUG: Wrong for 0-indexed if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] heapify_buggy(arr, n, largest) # CORRECT: Heapify with correct 0-indexed childrendef heapify_correct(arr, n, i): """Heapify subtree rooted at index i (max-heap, 0-indexed).""" largest = i left = 2 * i + 1 # CORRECT for 0-indexed right = 2 * i + 2 # CORRECT for 0-indexed if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] heapify_correct(arr, n, largest)Many algorithm textbooks use 1-indexed arrays, while most programming languages use 0-indexed arrays. When implementing textbook algorithms, you must translate ALL index formulas. Missing even one (like heap child indices) causes subtle bugs.
Beyond recognizing off-by-one errors, we can adopt practices that prevent them from occurring in the first place.
Strategy 1: Use Inclusive or Exclusive Bounds Consistently
Pick one convention and stick to it ruthlessly. Most modern languages favor exclusive upper bounds (Python's range, slice). If you always think in [start, end) terms, you eliminate a major source of confusion.
Strategy 2: Write Explicit Invariants
Before any loop, write a comment stating:
This forces you to think through the bounds explicitly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
# === STRATEGY: Explicit Invariant Comments === def find_pairs_with_sum(arr, target): """Find all pairs of indices (i, j) where arr[i] + arr[j] == target and i < j.""" n = len(arr) pairs = [] # Invariant: i is the index of the first element in potential pair # Range: i goes from 0 to n-2 (inclusive) - must leave room for j for i in range(n - 1): # Invariant: j is the index of the second element, where j > i # Range: j goes from i+1 to n-1 (inclusive) for j in range(i + 1, n): if arr[i] + arr[j] == target: pairs.append((i, j)) return pairs # === STRATEGY: Use Range Length Calculations === def process_all_windows(arr, window_size): """Process every window of size window_size in arr.""" n = len(arr) # The number of windows of size k in array of size n is: n - k + 1 # Verify: for n=5, k=3, windows start at 0, 1, 2 → 3 windows = 5-3+1 num_windows = n - window_size + 1 # Explicit length-based thinking prevents off-by-one for window_start in range(num_windows): window_end = window_start + window_size # Exclusive end window = arr[window_start:window_end] process(window) # === STRATEGY: Assert Invariants === def binary_search_with_assertions(arr, target): """Binary search with explicit invariant checking.""" if not arr: return -1 left, right = 0, len(arr) - 1 # [left, right] inclusive while left <= right: # Invariant: if target exists, it's in arr[left:right+1] assert 0 <= left <= right < len(arr), f"Invariant violated: {left}, {right}" mid = left + (right - left) // 2 assert left <= mid <= right, f"Mid out of bounds: {mid}" if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1 # === STRATEGY: Test Edge Cases Explicitly === def test_binary_search(): """Comprehensive edge case testing for binary search.""" search = binary_search_with_assertions # Empty array assert search([], 5) == -1 # Single element - found assert search([5], 5) == 0 # Single element - not found (greater) assert search([5], 10) == -1 # Single element - not found (smaller) assert search([5], 1) == -1 # Two elements - find first assert search([1, 5], 1) == 0 # Two elements - find second assert search([1, 5], 5) == 1 # Two elements - not found (between) assert search([1, 5], 3) == -1 # Target at very first position assert search([1, 2, 3, 4, 5], 1) == 0 # Target at very last position assert search([1, 2, 3, 4, 5], 5) == 4 # Target in middle assert search([1, 2, 3, 4, 5], 3) == 2 print("All tests passed!")num_iterations = end - start + 1 or similar before the loop.Off-by-one errors aren't just a learning problem—they cause real-world failures in production systems.
The Java Arrays.binarySearch Bug (2006)
For nearly two decades, the standard Java library contained an off-by-one bug in its binary search:
// The bug was in this line:
int mid = (low + high) / 2;
// When low + high exceeds Integer.MAX_VALUE, this overflows
// The fix:
int mid = low + ((high - low) / 2);
This wasn't strictly an off-by-one error in the traditional sense, but an overflow error that caused array index problems—demonstrating how boundary issues manifest in subtle ways.
The Heartbleed Bug (2014)
One of the most devastating security vulnerabilities in internet history was caused by failing to properly validate a length field:
// Simplified representation of the bug
memcpy(response, data, payload_length); // No check that payload_length <= actual_data_length
An attacker could request more data than was actually sent, causing the server to return memory beyond the intended buffer—potentially exposing passwords, private keys, and other sensitive data.
The Boeing 787 Integer Overflow (2015)
The 787 Dreamliner had a bug where the electrical generators would shut down after exactly 248 days of continuous operation. The cause: a counter that overflowed when it reached 2³¹ hundredths of a second—approximately 248.55 days.
These examples show that off-by-one and boundary errors can cost millions of dollars, compromise security for millions of users, and even create safety hazards. The discipline of correct boundary handling isn't pedantic—it's essential.
We've thoroughly explored the most common bug in programming. Let's consolidate our key insights:
What's Next
The next page covers another silent killer: integer overflow. Unlike off-by-one errors which often cause immediate test failures, overflow bugs can lurk undetected in code for years until just the right large input triggers them. Understanding overflow is essential for robust algorithmic code.
You now have a comprehensive understanding of off-by-one errors: their origins, patterns, prevention, and real-world impact. With practice, correct boundary handling becomes automatic, eliminating the most common category of algorithmic bugs.