Edge cases and boundary conditions
Most bugs live at the edges of the input space: nothing, one thing, the same thing twice, the smallest and largest values, and the exact boundary of a range. Walk a fixed checklist per input type before you code, ask about the cases that need a decision, and write each one down as a named test. It costs about three minutes and is one of the clearest signals of seniority an interviewer sees.
The idea in plain words
Section titled “The idea in plain words”A bridge inspector doesn’t test the middle of the span. They check where the bridge meets the bank, the joints, and what happens under the heaviest truck. Code is the same. The middle of the input space usually works. The edges are where loops run zero times, indices go out of range, and integers wrap around.
Testers call this boundary value analysis: split the inputs into groups that should behave the same (an equivalence class), then test at and just around each group’s boundary. Glenford Myers wrote it down in 1979 in The Art of Software Testing, and it still finds most bugs per minute spent.
The trick for interviews is to make it mechanical. You don’t need inspiration. You need a list per input type, which you run every time:
- Size: empty, one, two, huge.
- Values: zero, negative, duplicates, all equal, extremes (overflow).
- Order: sorted, reverse sorted, unsorted when you assumed sorted.
- Boundaries: exactly at a limit, one before, one after. Inclusive or exclusive?
- Ties: two answers that are equally good. Which one wins?
- Text: empty string, whitespace, case, unicode.
See it
Section titled “See it”Pick an input type. Each card is an edge case, the bug it usually exposes, and whether it crashes, gives a wrong answer, runs too slow, or is a question you should ask first. Tick the ones your code handles, then copy them out as a table-driven test.
import pytest
@pytest.mark.parametrize("xs", [
pytest.param([], id="empty"),
pytest.param([7], id="one element"),
pytest.param([7, 3], id="two elements"),
pytest.param([5, 5, 5], id="all equal"),
pytest.param([3, 9, 9], id="duplicated maximum"),
pytest.param([-4, -1, -9], id="all negative"),
pytest.param([0, 0, 1], id="zeros"),
pytest.param([1, 2, 3, 4, 5], id="sorted ascending"),
pytest.param([5, 4, 3, 2, 1], id="sorted descending"),
pytest.param([2147483647, 1], id="sum overflows int32"),
pytest.param([-2147483648], id="int32 minimum"),
pytest.param([1] * 100_000, id="large n"),
])
def test_edge_cases(xs: list[int]) -> None:
... # assert your_function(xs) == expectedWhen to reach for it
Section titled “When to reach for it”Always. But there are moments in a round when it pays the most:
| The moment… | What to do |
|---|---|
| right after hearing the problem | Run the checklist out loud for the input type. Turn every “it depends” into a clarifying question. |
| before writing the loop | Decide what empty and one-element input return. Write it as the first lines of the function, or explain why the general code already handles it. |
| the problem mentions time, ranges or windows | Pin down inclusive vs exclusive at both ends. Write it in a comment. |
| the problem mentions money, counts or IDs | Ask about size. If totals can pass 2³¹, use 64-bit integers in C# (or say why you don’t need to). |
| “return the top / best / first…” | Ask for the tie-break rule before you sort. |
| you think you’re done | Run the three smallest edge cases by hand. Then say what you’d still test with more time. |
How it works
Section titled “How it works”- Name the input types. A function
f(intervals, now)has two: a list of pairs and a timestamp. Each type gets its own checklist. - Run the checklist per type (the table below). For each line, decide: does it crash, give a wrong answer, or need a decision?
- Ask about the decisions. “If two players tie, who ranks first?” “Is an event exactly 60 seconds old still in the window?” Interviewers often leave these unspecified on purpose.
- Write the decisions down as a docstring or a comment at the top. This becomes your contract.
- Handle edges with a guard or with a general rule. A guard (
if not xs: return None) is explicit. A general rule (startbestatxs[0], use half-open ranges) makes the edge disappear. Prefer the general rule when it’s just as clear. - Turn each case into a named test in a table. A failure then tells you which idea broke.
The checklist by input type
Section titled “The checklist by input type”| Input type | Size | Values | Order / shape | Boundaries and ties |
|---|---|---|---|---|
| Integer array | [], [x], [x, y], 10⁵ items |
0, negatives, all equal, duplicate max, int.MaxValue, int.MinValue |
sorted, reverse sorted | k = 0, k = n, k > n |
| String | "", one char, 10⁵ chars |
case, spaces, tabs, digits, leading zeros | palindrome, all same char | combining marks, emoji (UTF-16 length 2) |
| Intervals | none, one | negative coords, zero-length [4, 4], start > end |
unsorted, contained, duplicate | touching [1,3]+[3,5]: closed or half-open? |
| Timestamps / windows | no events | t = 0, epoch milliseconds (> 2³¹) |
out of order, same tick | event exactly at now - window, exactly at now, in the future |
| Graph | no edges, one node | self-loop, duplicate edge | disconnected, cycle, very long path | start = target, target unreachable |
| Grid | [], [[]], 1×1 |
all walls, start blocked | one row, one column, not square | edge and corner cells (fewer neighbours) |
| Money / arithmetic | zero amount | negative, division by zero, overflow | rounding half (2.5), remainders when splitting | |
| Ranking / top-k | fewer than k items | negative scores | all tied | tie exactly at the k-th place |
Example: a function that survives the checklist
Section titled “Example: a function that survives the checklist”“Return the second largest distinct value” looks like a two-line problem. Run the integer-array checklist on it and five of the eight rows matter: empty, one element, all equal, duplicated max, all negative. The code below handles each one by construction, and the tests name them.
def second_largest(xs: list[int]) -> int | None: """Second largest *distinct* value, or None if there isn't one.""" first: int | None = None # 1. "nothing seen yet" is None, not 0: 0 would break negatives second: int | None = None for x in xs: if first is None or x > first: first, second = x, first # 2. old max slides down to second elif x != first and (second is None or x > second): second = x # 3. skip duplicates of the max return second
if __name__ == "__main__": # One line per edge case, named so a failure tells you which one broke. assert second_largest([]) is None # empty assert second_largest([7]) is None # one element assert second_largest([5, 5, 5]) is None # all duplicates assert second_largest([3, 9, 9]) == 3 # duplicated max assert second_largest([-4, -1, -9]) == -4 # all negative assert second_largest([9, 3]) == 3 # max first (sorted descending) assert second_largest([3, 9]) == 3 # max last (sorted ascending) assert second_largest([2**63, 1]) == 1 # beyond 64-bit: fine in Python print("ok")import assert from "node:assert/strict";
/** Second largest *distinct* value, or undefined if there isn't one. */export function secondLargest(xs: readonly number[]): number | undefined { let first: number | undefined; // 1. "nothing seen yet" is undefined, not 0 or -Infinity let second: number | undefined; for (const x of xs) { if (first === undefined || x > first) { [first, second] = [x, first]; // 2. old max slides down to second } else if (x !== first && (second === undefined || x > second)) { second = x; // 3. skip duplicates of the max } } return second;}
// One line per edge case, named so a failure tells you which one broke.assert.equal(secondLargest([]), undefined); // emptyassert.equal(secondLargest([7]), undefined); // one elementassert.equal(secondLargest([5, 5, 5]), undefined); // all duplicatesassert.equal(secondLargest([3, 9, 9]), 3); // duplicated maxassert.equal(secondLargest([-4, -1, -9]), -4); // all negativeassert.equal(secondLargest([9, 3]), 3); // max firstassert.equal(secondLargest([3, 9]), 3); // max lastassert.equal(secondLargest([Number.MAX_SAFE_INTEGER, 1]), 1); // largest exact integerconsole.log("ok");// One line per edge case, named so a failure tells you which one broke.Check(SecondLargest([]) is null, "empty");Check(SecondLargest([7]) is null, "one element");Check(SecondLargest([5, 5, 5]) is null, "all duplicates");Check(SecondLargest([3, 9, 9]) == 3, "duplicated max");Check(SecondLargest([-4, -1, -9]) == -4, "all negative");Check(SecondLargest([9, 3]) == 3, "max first");Check(SecondLargest([3, 9]) == 3, "max last");Check(SecondLargest([int.MaxValue, int.MinValue]) == int.MinValue, "extreme values");Console.WriteLine("ok");
// Second largest *distinct* value, or null if there isn't one.static int? SecondLargest(int[] xs){ int? first = null, second = null; // 1. "nothing seen yet" is null, not 0 or int.MinValue foreach (int x in xs) { if (first is null || x > first) { (first, second) = (x, first); // 2. old max slides down to second } else if (x != first && (second is null || x > second)) { second = x; // 3. skip duplicates of the max } } return second;}
static void Check(bool ok, string name) { if (!ok) throw new Exception($"edge case failed: {name}"); }The two choices that remove most edge cases:
- “Nothing yet” is
None/undefined/null, not 0. Startingfirst = 0silently breaks the all-negative case. Starting atint.MinValuebreaks when the input containsint.MinValue. - Duplicates of the max are skipped explicitly (
x != first), because “second largest” in[3, 9, 9]should be 3.
Example: boundaries you have to decide
Section titled “Example: boundaries you have to decide”Ranges and midpoints are where off-by-one bugs live. Two conventions remove most of them: half-open ranges [start, end) (start included, end excluded) and an overflow-safe midpoint.
def overlaps(a_start: int, a_end: int, b_start: int, b_end: int) -> bool: """Half-open ranges [start, end): end is NOT included, so touching ranges don't overlap.""" # 1. An empty range [x, x) holds nothing. Without this guard, [3, 3) "overlaps" [1, 10). if a_start >= a_end or b_start >= b_end: return False # 2. Otherwise they overlap unless one ends at or before the other starts. return a_start < b_end and b_start < a_end
def midpoint(lo: int, hi: int) -> int: """Floor midpoint that can't overflow in fixed-width languages.""" # 3. lo + (hi - lo) // 2 never exceeds hi; (lo + hi) // 2 can overflow in C#/Java. return lo + (hi - lo) // 2
if __name__ == "__main__": assert overlaps(1, 5, 4, 8) # partial overlap assert not overlaps(1, 5, 5, 8) # touching: 5 is not inside [1, 5) assert overlaps(1, 10, 3, 4) # containment assert not overlaps(3, 3, 1, 10) # empty range [3, 3) overlaps nothing assert midpoint(0, 0) == 0 assert midpoint(2, 3) == 2 # floor, so the loop still makes progress assert midpoint(-7, -2) == -5 # floor works for negatives too print("ok")import assert from "node:assert/strict";
/** Half-open ranges [start, end): end is NOT included, so touching ranges don't overlap. */export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { // 1. An empty range [x, x) holds nothing. Without this guard, [3, 3) "overlaps" [1, 10). if (aStart >= aEnd || bStart >= bEnd) return false; // 2. Otherwise they overlap unless one ends at or before the other starts. return aStart < bEnd && bStart < aEnd;}
/** Floor midpoint. JS numbers don't overflow at 2^31, but they lose precision past 2^53. */export function midpoint(lo: number, hi: number): number { // 3. Math.floor, not Math.trunc: trunc rounds -4.5 up to -4, floor gives -5. return lo + Math.floor((hi - lo) / 2);}
assert.ok(overlaps(1, 5, 4, 8)); // partial overlapassert.ok(!overlaps(1, 5, 5, 8)); // touching: 5 is not inside [1, 5)assert.ok(overlaps(1, 10, 3, 4)); // containmentassert.ok(!overlaps(3, 3, 1, 10)); // empty range [3, 3) overlaps nothingassert.equal(midpoint(0, 0), 0);assert.equal(midpoint(2, 3), 2); // floor, so the loop still makes progressassert.equal(midpoint(-7, -2), -5); // floor works for negatives tooconsole.log("ok");Check(Overlaps(1, 5, 4, 8), "partial overlap");Check(!Overlaps(1, 5, 5, 8), "touching: 5 is not inside [1, 5)");Check(Overlaps(1, 10, 3, 4), "containment");Check(!Overlaps(3, 3, 1, 10), "empty range [3, 3) overlaps nothing");Check(Midpoint(2, 3) == 2, "floor, so the loop still makes progress");Check(Midpoint(int.MaxValue - 1, int.MaxValue) == int.MaxValue - 1, "no overflow near int.MaxValue");// The naive version wraps around to a negative number (unchecked is the C# default):int lo = int.MaxValue - 1, hi = int.MaxValue;Check(unchecked(lo + hi) / 2 < 0, "naive (lo + hi) / 2 overflows");Console.WriteLine("ok");
// Half-open ranges [start, end): end is NOT included, so touching ranges don't overlap.// 1. An empty range [x, x) holds nothing. Without that guard, [3, 3) "overlaps" [1, 10).// 2. Otherwise they overlap unless one ends at or before the other starts.static bool Overlaps(int aStart, int aEnd, int bStart, int bEnd) => aStart < aEnd && bStart < bEnd && aStart < bEnd && bStart < aEnd;
// 3. lo + (hi - lo) / 2 never exceeds hi. Valid for lo <= hi with both non-negative,// which is the binary-search case; / truncates toward zero, so negatives need care.static int Midpoint(int lo, int hi) => lo + (hi - lo) / 2;
static void Check(bool ok, string name) { if (!ok) throw new Exception($"failed: {name}"); }What it costs
Section titled “What it costs”| Time in the round | What it buys | |
|---|---|---|
| Running the checklist out loud | 1-2 min | Clarifying questions that show judgment; no surprise follow-ups |
| Guards or general rules in code | a few lines | No crashes on the interviewer’s first test |
| Named edge-case tests | 2-3 min | Fast diagnosis when something fails; reusable at each new level |
| Skipping all of it | 0 min now | The interviewer finds the bug, and that costs more than the minutes |
The real cost is attention, not time. A checklist turns it into routine, so the thinking goes into the problem.
Variations you’ll meet
Section titled “Variations you’ll meet”- Validate vs assume. In a library function, validate at the boundary and raise a clear error (
ValueError,RangeError,ArgumentOutOfRangeException). In an algorithm round, state the assumption (“I’ll assumek ≥ 1”) and move on. Ask which one they want. - Sentinel vs optional. Returning
-1for “not found” is fine for indices. For values that can be any integer, returnNone/undefined/int?instead, so a real-1isn’t confused with “nothing”. - Edges that are about time, not data. Calls in an unexpected order:
return_bookbeforecheckout,popon an empty queue, the same request twice. These are the edge cases of stateful APIs. See input validation and idempotency. - Scale as an edge. n = 10⁵ isn’t wrong, it’s slow: an O(n²) loop does 10¹⁰ steps, and recursion 10⁵ deep overflows the stack (Python’s default limit is 1000 frames).
Pitfalls
Section titled “Pitfalls”- Falsy zero.
if x:/if (x)treats 0 (and"") as missing. Compare withNone/undefinedexplicitly. ifwhere you neededwhile. “Evict the expired event” withifevicts one; a gap longer than the window needs all of them gone.- Fixed-width overflow.
intsums,(lo + hi) / 2,Math.Abs(int.MinValue), andint.MinValue / -1all overflow in C#. JS integers silently lose precision past 2⁵³. - Division semantics.
-7 // 2is -4 in Python but-7 / 2is -3 in C#, and%follows different signs. See the toolkit. - String length is not characters.
"👍".lengthis 2 in JS and C#;"é"can be one or two code points. - Fixing one edge by breaking the general case. A special-case
iffor[x]that returns early can skip work the general path needed. Re-run the normal tests after every guard you add.
In the interview
Section titled “In the interview”- After the problem statement: “Let me check a few edges: can the list be empty? Can values be negative? If two items tie, which comes first?” Ask only the ones that change the code.
- Say the decisions out loud and write them at the top: “Empty input returns an empty list. Intervals are half-open, so touching ones don’t merge.”
- When done, trace the smallest cases: empty, one element, two elements in both orders.
- Expect the follow-up “What inputs would break this?” Answer with the checklist, not with a guess.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Run the checklist on each before writing a line, then write the table of named cases:
- Return the index of the first non-repeating character in a string.
- Rotate an array right by
kplaces (kcan exceed the length, or be 0). - Given sorted, possibly duplicated integers, return how many times
xappears. - Parse
"HH:MM"strings and return the minutes between two times (what about crossing midnight?). - Split a bill of
amountcents betweennpeople so the shares differ by at most one cent.
Go deeper
Section titled “Go deeper”- Glenford J. Myers, The Art of Software Testing (3rd ed., 2011), ch. 4: equivalence partitioning and boundary-value analysis.
- E. W. Dijkstra, “Why numbering should start at zero” (EWD831): the argument for half-open ranges.
- Python reference: binary arithmetic operations (floor division and modulo) and C#: checked and unchecked (overflow).
- MDN: String length: why it counts UTF-16 code units, not characters.