skip to content

Why does sql.DB.QueryRowContext return no error, and where does a failed query surface?

level: middleimportance: must knowfreq 70%

answer

  1. one return value, so it chains
  2. the error is only postponed
  3. zero rows is an error value here
  4. missing id is a 404, not a 500
  5. the row holds a connection until Scan

basics

~20 s

QueryRowContext returns only a *sql.Row so the call can be chained straight into Scan; the query's error is stored and surfaces from Row.Scan. Scan returns sql.ErrNoRows when nothing matched, which callers must handle separately from a real failure.

solid answer

~40 s

`(*sql.DB).QueryRowContext` returns a `*sql.Row` and nothing else, which is what lets you write `db.QueryRowContext(ctx, q, id).Scan(&email)` as one expression. The price is that the error is deferred: any failure starting the query is remembered inside the `*sql.Row` and returned by `Scan`. `Scan` therefore returns three interesting outcomes — nil, `sql.ErrNoRows` when the query matched no rows, or any other error. The distinction matters because "no such row" is usually a normal business outcome (a 404 from an HTTP handler) while anything else is a genuine failure (a 500). So the handler branches on `errors.Is(err, sql.ErrNoRows)` first and treats the remaining non-nil case as internal. A second consequence: the `*sql.Row` holds a connection until `Scan` is called, so a `*sql.Row` you build and never scan leaks it.

code

go · 13 lines
go
func (s *server) getUser(w http.ResponseWriter, r *http.Request) {
	var email string
	err := s.db.QueryRowContext(r.Context(),
		"SELECT email FROM users WHERE id = ?", r.PathValue("id")).Scan(&email)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		http.Error(w, "not found", http.StatusNotFound)
	case err != nil:
		http.Error(w, "internal error", http.StatusInternalServerError)
	default:
		fmt.Fprintln(w, email)
	}
}

go deeper

for a junior

Remember that the whole call is one expression ending in Scan, and that Scan is where you check the error. Know that sql.ErrNoRows means the query ran fine and simply matched nothing.

for a middle

Explain why the error is deferred, that the *sql.Row holds a pooled connection until Scan releases it, and that extra matching rows are silently discarded rather than reported.

for a senior

Demonstrate the operational consequence: missing rows must not burn the error budget as 500s, and a cancelled request context surfaces here as a non-ErrNoRows error that should be classified as such rather than logged as a database fault.

for a principal

Own where the translation happens. Decide whether sql.ErrNoRows is allowed to escape the data layer into handlers at all, or whether each repository converts it into a domain-level not-found error so that nothing outside the data layer imports database/sql.

## An API deliberately shaped for one row `(*sql.DB).QueryRowContext(ctx, query string, args ...any) *sql.Row` has an unusual signature for Go: a single return value, no error. That is a deliberate ergonomics choice. The overwhelmingly common single-row read is ``` var email string err := db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email) ``` and that one-liner is only possible because the call itself cannot fail in a way you must check on the spot. Had it returned `(*sql.Row, error)` you would need a temporary variable and two error checks for what is conceptually one operation. ### Where the error went The error did not disappear; it was **deferred**. If the query cannot be started — bad SQL, a dead connection, a context already cancelled — the `*sql.Row` records that error internally and `Scan` returns it. The rule to carry into an interview: **for `QueryRowContext` there is exactly one error check, and it is the one on `Scan`.** `(*sql.Row).Err()` also exists for the case where you want to know whether the query failed without scanning anything, and it reports the same error `Scan` would. ### `sql.ErrNoRows` When the query is fine but selects zero rows, `Scan` returns `sql.ErrNoRows`. This is the single most important behaviour of the type, because it collapses two very different situations into one `error` value that a careless handler treats identically: - **No row matched.** The id in the URL does not exist. This is not a bug and not an outage; the correct HTTP response is `404 Not Found`. - **Something broke.** The database is unreachable, the column list changed, the context was cancelled. The correct response is `500` (or `503`), plus a log line and, eventually, an alert. A handler that returns 500 for a missing id makes the error budget look terrible and hides real incidents in the noise; one that returns 404 for a dropped connection tells the caller the resource does not exist when in fact nobody knows. Compare with `errors.Is(err, sql.ErrNoRows)` rather than `==`, since the error may have been wrapped on its way up through a repository layer. ``` switch { case errors.Is(err, sql.ErrNoRows): http.Error(w, "not found", http.StatusNotFound) case err != nil: http.Error(w, "internal error", http.StatusInternalServerError) default: // render the row } ``` Note the ordering: the `sql.ErrNoRows` case must come **before** the generic `err != nil` case, because `sql.ErrNoRows` is itself a non-nil error. ### What happens to extra rows If the query selects more than one row, `(*sql.Row).Scan` reads the **first** row and discards the rest — it does not report an error. That makes `QueryRowContext` a poor way to detect a uniqueness violation: a query that should match one row but matches five looks identical to the correct case. When "exactly one" is a real invariant, either enforce it in the schema or use `QueryContext` and count. ### The connection is held until you `Scan` Under the hood a `*sql.Row` wraps a result set, so it is holding a connection from the pool. `Scan` reads the row and releases that connection. A `*sql.Row` that is created and then abandoned — stored in a struct, or produced in a branch that returns early — never releases it. In practice this means: **call `Scan` (or `Err`) on every `*sql.Row` you create**, ideally in the same expression that created it. ### Context, as always The `ctx` is the query's lifetime. In an HTTP handler, passing `r.Context()` means that when the client goes away the query is cancelled and `Scan` returns a context error rather than blocking a connection on work whose result nobody wants. That context error is *not* `sql.ErrNoRows`, so it falls into the generic error branch, which is exactly right. ### When to prefer `QueryContext` Use `QueryRowContext` when the query is a lookup by a key you believe is unique. Use `QueryContext` and the `Next`/`Scan`/`Err` loop when you need zero-or-many, when you must distinguish "one" from "several", or when you want to stream rather than materialise. Mixing them up is not a correctness disaster, but reaching for `QueryRowContext` on a query that legitimately returns many rows silently throws data away.

  • What does Scan do if that single-row query actually matches five rows?
    It scans the first row and discards the rest, with no error. So `QueryRowContext` cannot be used to detect a broken uniqueness assumption — the duplicate case is indistinguishable from the correct one. If "exactly one" is an invariant you need to verify, enforce it with a unique constraint or run `QueryContext` and count the rows you actually got.
  • Why does *sql.Row have an Err method when Scan already returns the error?
    `(*sql.Row).Err` lets you learn whether starting the query failed without committing to scanning destinations — useful in wrappers and tests that only want to know the query was accepted. It reports the same deferred error `Scan` would return. It does not, however, tell you whether a row existed; that answer only comes from `Scan` returning `sql.ErrNoRows`.
  • Why check errors.Is(err, sql.ErrNoRows) instead of err == sql.ErrNoRows?
    Because a repository or service layer usually wraps the error with `fmt.Errorf("...: %w", err)` to add context, and a wrapped error is not equal to the sentinel under `==`. `errors.Is` unwraps the chain, so the handler keeps working when someone adds context somewhere in the middle. The `==` form works only as long as nobody ever wraps.

saying these in an interview costs you the question

  • Says QueryRowContext returns an error you check immediately
  • Maps sql.ErrNoRows to a 500 response
  • Puts the generic err != nil case before the ErrNoRows case
  • Thinks a nil error from Scan can still mean zero rows
  • Expects Scan to complain when the query matched several rows
  • Creates a *sql.Row and never calls Scan or Err on it