skip to content

An HTTP handler sums a column over rows from db.QueryContext and its total comes back short by a few rows on some requests, with no error logged. How do you find the cause?

level: seniorimportance: should knowfreq 45%

answer

  1. wrong quietly, never loudly
  2. one bool, two possible meanings
  3. the loop exit hides a failure
  4. continue on a Scan error drops data
  5. the check that belongs after the loop

basics

~10 s

The loop almost certainly ignores rows.Err(). rows.Next() returns false both at the end of data and on a mid-iteration failure, so a truncated result set looks complete; a swallowed Scan error does the same.

solid answer

~50 s

The symptom — sometimes short, never an error — is the signature of an unchecked `rows.Err()`. `(*sql.Rows).Next` returns `false` for two different reasons, end of data and failure during iteration, and it returns no error itself; the failure is only retrievable from `rows.Err()` after the loop. So a request whose context was cancelled mid-stream, or whose connection dropped, ends the loop early and the handler happily sums what it got. The second suspect is inside the loop: a `Scan` error handled with `continue` or a bare `log` line skips rows without failing the request. I would read the loop first, then reproduce it in an integration test against a real driver — cancel the context after the first row and assert that the helper returns an error rather than a partial total — then fix it by returning `rows.Err()` and by never swallowing a `Scan` error.

code

go · 11 lines
go
var total int64
for rows.Next() {
	var amount int64
	if err := rows.Scan(&amount); err != nil {
		continue // this row silently vanishes from the total
	}
	total += amount
}
// rows.Err() is never consulted, so a cancelled context or a
// dropped connection also returns a short total with err == nil
return total, nil

go deeper

for a junior

Remember the rule that produces this bug: always check rows.Err() after a for rows.Next() loop, and never use continue to skip a row whose Scan failed.

for a middle

Explain the mechanism — Next returns a single bool for two different outcomes, carries no error, and rows.Err() is the only way to tell a clean end of data from an interrupted one.

for a senior

Reason from the symptom to the cause: silently wrong rather than failing points at a swallowed error, cancellation explains the intermittency, and you should propose an integration test against a real driver that cancels mid-iteration to prove it.

for a principal

Own the systemic answer. Decide whether read loops are hand-written at every call site or funnelled through one helper that cannot forget rows.Err, and whether business-critical aggregates get an independent reconciliation check rather than trusting a code path with no error signal.

## The symptom tells you where to look "Short by a few rows, sometimes, with no error anywhere" is one of the few Go database bugs with a nearly unique fingerprint. Nothing in `database/sql` throws away rows on its own. Something in the loop is ending early or skipping, and doing so without producing an error your code returns. ### Why `Next` can lie `(*sql.Rows).Next() bool` advances the cursor and returns `false` when there is nothing more to read. That covers two very different states: 1. The result set was fully consumed. This is success. 2. Iteration failed — the context was cancelled, the connection dropped, the driver hit a protocol or decode error mid-stream. This is failure. `Next` has no error return, so at the point the loop exits the two are indistinguishable. `(*sql.Rows).Err()` is the only thing that separates them: it returns the error that ended iteration, or `nil` at a clean end of data. A loop that ends with `return total, nil` instead of checking `rows.Err()` converts every mid-stream failure into a plausible-looking wrong answer. This is worse than a crash, because the result is *almost* right — a total short by a few rows is exactly the kind of number that survives review and gets reconciled against by a human weeks later. ### The three ways rows go missing **1. Unchecked `rows.Err()`.** The primary suspect, above. **2. A swallowed `Scan` error.** Inside the loop, `if err := rows.Scan(&amount); err != nil { continue }` — or a `log.Printf` with no return — skips the row and keeps going. Now a single unconvertible value quietly reduces the total. A `continue` on a `Scan` error is almost never right; the row you failed to read is data you are now silently omitting. **3. An early `break` on a condition that is not really terminal**, for example breaking out on the first zero value, or a `LIMIT` that was tuned for a smaller dataset. These are ordinary logic bugs but produce the same shape of symptom. A fourth, less common: the deferred `rows.Close()`'s error return is discarded. That error is usually redundant with `rows.Err()`, so it is the least informative of the four — but a reviewer noticing it often notices the missing `Err` check on the way. ### The cancellation case, concretely In an HTTP handler that passes `r.Context()` into `QueryContext`, a client that gives up mid-response cancels the context. `database/sql` closes the result set, `Next` returns `false`, and `rows.Err()` reports the cancellation. If nobody checks, the handler finishes computing a partial total and writes it. That explains the *intermittency* in the report: it happens only on requests that were interrupted, or that raced a connection being retired, so it never reproduces in a quiet environment and never reproduces under a single manual test. ### Reproducing it deliberately The way to turn this from a theory into a fact is an integration test against a real driver rather than a unit test with a fake. Run a query that returns many rows, cancel the context — or close the underlying connection — after the first row, and assert that the helper under test returns a **non-nil error**. A correct implementation fails the request; the buggy one returns a short total and `nil`, and the test catches it. Keep that test: it is the regression guard for a bug class that is invisible in production telemetry, because there is no error to count. ### The fix ``` var total int64 for rows.Next() { var amount int64 if err := rows.Scan(&amount); err != nil { return 0, err } total += amount } if err := rows.Err(); err != nil { return 0, err } return total, nil ``` Or, more compactly for a helper whose last statement is the return: `return total, rows.Err()`. ### Making it not happen again The compiler cannot help here — the buggy version is perfectly valid Go — and `go vet` has no check for it, so this is a review habit plus a structural one. The durable answer is to funnel reads through a small helper that owns the loop and returns `rows.Err()`, so individual call sites cannot forget. When counts and totals are business-critical, the second layer of defence is a reconciliation check that compares the aggregate against an independently computed one; the person who noticed the discrepancy in the first place was doing that by hand. ### What an interviewer is grading Not the fix — anyone can add one line. They are grading whether you *predicted* the cause from the symptom (silently wrong, not failing), whether you know that `Next` conflates two outcomes, and whether you proposed a way to prove it rather than just patching and hoping.

  • Why does this bug reproduce intermittently rather than on every request?
    Because it needs iteration to be interrupted, and most requests are not. A client that disconnects mid-response cancels the context; a connection retired by the pool or a network blip does the same. Quiet environments and single manual tests never interrupt anything, so the loop always reaches a clean end of data and the missing `rows.Err()` check costs nothing visible.
  • How would you prove the diagnosis rather than just adding the missing check?
    Write an integration test against a real driver: run a query returning many rows, cancel the context after the first `Next`, and assert the helper returns a non-nil error. The buggy version returns a partial total and `nil`, so the test fails before the fix and passes after. Keep it, because production telemetry cannot see this bug — there is no error to count.
  • Is handling a Scan error with continue ever defensible?
    Almost never in an aggregate. A `Scan` failure means a row you were asked to include could not be read, and skipping it changes the answer while reporting success. If some rows are genuinely optional, that has to be an explicit, counted decision — record how many were skipped and surface it — not a bare `continue` that leaves the caller with no way to know.
  • Does checking the error returned by the deferred rows.Close catch this too?
    Not reliably. `Close`'s error is usually redundant with what `rows.Err()` already reports, and the deferred form discards it by default. It is the weakest of the available signals: check `rows.Err()` after the loop, which is the method that exists precisely to answer whether iteration ended cleanly.

saying these in an interview costs you the question

  • Blames the SQL or the database before reading the loop
  • Says Next returning false proves the result set was complete
  • Adds retries instead of finding the swallowed error
  • Handles a Scan error with continue inside an aggregate loop
  • Expects go vet or the compiler to catch a missing rows.Err check
  • Patches the code without a test that reproduces the truncation