Null, Empty, and Boundary Edge Cases: The Reviewer's Systematic Checklist
Empty collections, zero, duplicates, timezones, and unicode boundaries — a fixed checklist for finding the valid inputs a pull request author never pictured.
Reviewer Detection Checklist
Defect Patterns & Fixes
Aggregation over an empty collection
def average_score(submissions: list[Submission]) -> float:
total = sum(s.overall_score for s in submissions)
return round(total / len(submissions), 1)
# First-ever page load for a new user:
average_score([]) # ZeroDivisionError -> 500 on the profile pageHow to Spot It in Reviews:
- •Any division where the divisor is a len(), count, or size of caller-supplied data.
- •max(), min(), first(), and [0] on a collection whose emptiness is not established above.
- •Ask specifically about the newest user, the newest tenant, and the freshly created record.
- •Check what the chosen empty value means downstream — 0 in an average and 0 in a sum are not the same claim.
Non-idempotent webhook handler
@PostMapping("/webhooks/payments")
public ResponseEntity<Void> onPayment(@RequestBody PaymentEvent event) {
Wallet wallet = wallets.findByCustomer(event.getCustomerId());
wallet.credit(event.getAmount());
wallets.save(wallet);
return ResponseEntity.ok().build();
}How to Spot It in Reviews:
- •Any handler for a webhook, queue message, or retryable RPC that performs a write with a side effect.
- •Ask: 'if this exact request arrives twice, what is different the second time?' A correct answer is 'nothing'.
- •A dedupe implemented as if (exists) return; followed by an insert is still racy — look for the unique constraint.
- •Check whether the dedupe store has a retention policy; unbounded is a slower defect but still one.
Server clock read inside domain logic
public bool IsTrialExpired(Subscription subscription)
{
return DateTime.Now > subscription.TrialEndsAt;
}How to Spot It in Reviews:
- •Grep for DateTime.Now, LocalDate.now(), datetime.now(), and time.Now() inside anything that is not a composition root or logging.
- •DateTime where DateTimeOffset is meant — a timestamp with no offset is missing information.
- •Ask how a test would assert the behaviour one second before and one second after the boundary.
- •Date arithmetic that assumes 24-hour days or fixed month lengths breaks on DST and on 31 January.
Truncation that splits a multi-byte character
func truncate(name string, max int) string {
if len(name) <= max {
return name
}
return name[:max] // len() and slicing are in bytes
}
truncate("Zoë Ramírez", 4) // "Zo\xc3" — invalid UTF-8How to Spot It in Reviews:
- •Any slicing or substring against a length limit, especially one that matches a database column width.
- •A limit expressed as a bare number with no unit in its name: bytes, runes, characters, or grapheme clusters?
- •Test data that is entirely ASCII is a signal the author did not consider it — ask for one accented or emoji case.
- •Combining characters and emoji mean even rune counts can split a user-perceived character; if display width matters, say so explicitly.
Why edge cases are the defect class reviewers are best placed to find
An edge case is a valid input the author never pictured. Empty list. One element. Zero. Negative. A tenant with no rows yet. A retry that arrives after the record was deleted. A user in a timezone where the day has 23 hours. The code is not wrong in an interesting way — it simply never considered the input, and the author cannot see the gap because they are still holding the picture that produced the code.
The reviewer is not holding that picture. That asymmetry is the whole value of a second reader, and it is why "what happens when this list is empty?" catches more real bugs than any static analysis tool.
What it costs in production
- Day-one failures. New tenants, new accounts, and fresh environments hit the empty-collection path first. Division by
len(items)means the shiniest customer sees a 500 on their first login. - End-of-month failures. Date logic that adds a month to 31 January, or assumes every day has 24 hours, breaks on a schedule you can predict and still nobody does.
- Retry-triggered failures. At-least-once delivery guarantees mean the second copy of a message is the normal case, not the exception. Code that assumes the row still exists, or that the operation has not already run, breaks on the retry.
- Boundary corruption. Truncating a string at a fixed byte length splits a multi-byte character and produces data the next consumer cannot parse.
- Scale-triggered failures. A limit that was generous at 100 rows (
INclause size, request body size, page size) becomes a hard error at 10,000.
Edge-case failures cluster at the least convenient moments — onboarding, month end, incident recovery, and traffic spikes — because those are exactly the conditions that produce unusual inputs.
How to spot it in review
Work from a fixed list. Human attention drifts; a checklist does not.
For every collection: empty, exactly one, exactly the page/batch size, and larger than any limit downstream. Look specifically for aggregation — sum / len(items), max(...), items[0], list.First() — all of which have an empty-input failure mode.
For every number: zero, negative, the maximum, and the value that makes a divisor zero. Then ask whether the type can even hold the result — an int counter of bytes overflows quietly in C# and Java, panics in debug Rust, and wraps in release Rust.
For every string: empty, whitespace only, very long, and non-ASCII. Byte length is not character length is not display width. Truncation and fixed-size database columns are where this bites.
For every time value: what timezone is it in, what happens across a DST boundary, and is it derived from the server clock inside domain logic? DateTime.Now, LocalDate.now(), and time.Now() buried in a business rule make the behaviour untestable and environment-dependent.
For every external input: absent field, null, wrong type, duplicate delivery, and out-of-order delivery. If the PR consumes a queue or a webhook, idempotency is an edge case and not an optimisation.
For every state transition: what if it is already in the target state? Cancelling a cancelled order, refunding a refunded payment, and closing a closed connection all need a defined answer.
Note
A quick test for whether the author considered the edge: does the diff contain a test with an empty input, a zero, or a duplicate? Tests are where "I thought about it" leaves a trace.
Fixing it without breaking something else
Handling an edge case means choosing a behaviour, and that choice is a contract change.
- Returning early on empty changes the caller's contract. Is an empty input a success returning zero, or an error? A payment total of
0.0for an empty basket may be correct — or it may mean a caller silently charges nothing. - Adding validation converts wrong results into errors. That is usually right, but it moves the failure to a new place. Existing callers that were relying on the lenient behaviour will now throw, so check every call site and every retry policy that will now retry a permanent failure forever.
- Idempotency needs a key and a store, and both need lifetimes. A dedupe table without a retention policy becomes an unbounded table. A dedupe window shorter than the retry window does not dedupe.
- Clamping hides the caller's bug.
max(0, stock)makes negative inventory invisible while leaving whatever produced the negative number in place. - Widening a type or a limit has downstream reach. Changing an
intcolumn tobigint, or a 255-char field totext, touches every consumer, every serialiser, and possibly an index.
How not to over-engineer the fix
Edge cases invite defensive sprawl. A reviewer who asks for every input to be validated at every layer produces code where the real logic is 10% of the lines and every function re-checks what its caller already checked.
Principles that keep it proportionate:
- Validate once, at the boundary. Parse untrusted input into a type that cannot be invalid, then trust that type inwards. A
PositiveQuantitybeats aquantity > 0check in nine functions. - Prefer making the state unrepresentable over checking for it. A non-empty list type, a required constructor argument, or a database
NOT NULLremoves the branch instead of guarding it. - One guard clause, not a nest. If the fix adds three levels of
if, the edge case is telling you the function has too many responsibilities. - Do not add a retry/circuit-breaker/queue for an input problem. Infrastructure does not fix an unhandled empty list.
- Do not invent behaviour for edges the product has not defined. If nobody knows what a refund on a cancelled order should do, the review comment is "this case is undefined — confirm with product", not a guess implemented in code.
Tip
The cheapest possible edge-case fix is a parameterised test that includes the edge value. If the behaviour is already correct, you have documented it for free; if it is not, you have found the defect without writing any production code.
Comment templates that an agent can act on
- "
sum(scores) / len(scores)divides by zero when a user has no submissions yet — returnNonefor the empty case and let the caller render a dash." - "This handler is not idempotent: a duplicate webhook delivery charges twice. Key on
event_idand short-circuit on a repeat." - "
getItems()may be empty here;items.get(0)throws. Guard the empty case before indexing." - "
LocalDate.now()inside the pricing rule makes this untestable and server-timezone dependent — take aClockparameter." - "Truncating to 255 bytes can split a UTF-8 character; truncate by code points or store the full value."
Review Defective Code in the Workbench
Test your ability to spot this defect in our interactive Monaco-powered PR code editor.
