After an UPDATE via sql.DB.ExecContext, how do you tell the caller no row matched?
answer
- no rows come back, so no cursor
- the Result interface has two methods
- both of them also return an error
- a successful statement can match nothing
- changed rows are not always matched rows
basics
~10 ssql.DB.ExecContext returns a sql.Result; call Result.RowsAffected() and treat a count of zero as no matching row. It returns an error as well, because not every driver can report a count.
solid answer
~40 s`(*sql.DB).ExecContext` is the statement-without-rows path; it returns `(sql.Result, error)`. The first error covers running the statement. To learn whether the `UPDATE` actually hit anything you call `res.RowsAffected() (int64, error)` and treat `0` as "no such row" — the natural 404 for an update-by-id handler. The second error is not decoration: `sql.Result` is an interface implemented by the driver, and a driver that cannot report the count returns an error here rather than a misleading zero. There is one portability trap worth naming: some databases report rows *changed* rather than rows *matched*, so an update that sets a column to the value it already holds can report 0 even though the row exists. If the difference matters, verify existence explicitly instead of inferring it from the count.
code
go · 14 linesfunc updateEmail(ctx context.Context, db *sql.DB, id int64, email string) error {
res, err := db.ExecContext(ctx, "UPDATE users SET email = ? WHERE id = ?", email, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err // this driver does not report the count
}
if n == 0 {
return fmt.Errorf("user %d not found", id)
}
return nil
}go deeper
Know that ExecContext is what you call for INSERT, UPDATE and DELETE, that it hands back a sql.Result rather than rows, and that RowsAffected is how you learn how many rows the statement touched.
Explain why both sql.Result methods return an error, that the driver supplies the implementation, and why a statement that matched nothing is still a successful statement.
Show judgment about the count's meaning: matched versus changed rows differ by database, zero can mean a lost optimistic-concurrency race, and the assumption you build a 404 on should be pinned by an integration test against the real driver.
Own the API contract that sits on top. Decide as a standard whether a delete of a missing row is a 404 or an idempotent success, and whether services are allowed to depend on affected-row semantics that would change under a database migration.
## `ExecContext` is for statements that return no rows `(*sql.DB).ExecContext(ctx, query string, args ...any) (sql.Result, error)` runs a statement — `INSERT`, `UPDATE`, `DELETE`, DDL — and returns a `sql.Result` instead of a cursor. Because there is no result set, there is no `*sql.Rows` holding a connection: the connection goes back to the pool as soon as the statement finishes. That is why using `QueryContext` for a write is a real (if mild) bug — it hands you a `*sql.Rows` you then have to remember to close. ### `sql.Result` has exactly two methods ``` type Result interface { LastInsertId() (int64, error) RowsAffected() (int64, error) } ``` Both return an error, and that is the design point candidates usually miss. `sql.Result` is implemented by the driver, not by `database/sql`, and not every database can answer either question. A driver that has no answer returns an error rather than inventing a number. So `n, err := res.RowsAffected()` needs its own error check; ignoring it and using `n` means you are trusting a zero you never verified. `LastInsertId` is the more portable-looking and less portable of the two: it is meaningful on databases with a single auto-increment concept and unsupported on others, where the idiomatic path is a `RETURNING`-style clause read back with `QueryRowContext` instead. ### Turning the count into an API answer The common handler shape is an update by id where the caller deserves to know the id was wrong: ``` res, err := db.ExecContext(ctx, "UPDATE users SET email = ? WHERE id = ?", email, id) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return fmt.Errorf("user %d not found", id) } ``` Note that `err == nil` from `ExecContext` says only that the statement executed. An `UPDATE` whose `WHERE` clause matches nothing is a perfectly successful statement. Without the `RowsAffected` check, a `PUT /users/9999` on a nonexistent id returns `204 No Content` and the caller believes it wrote something. ### Matched versus changed The one portability caveat to state out loud: `RowsAffected` reports what the database chose to report. Some databases count rows *changed* by the statement, so `UPDATE users SET email = '[email protected]' WHERE id = 1` on a row that already holds that email reports `0` even though the row exists and the statement was correct. Other databases count rows *matched*, and report `1`. Code that maps `0` to a 404 is therefore making an assumption about the database underneath it, and that assumption should be pinned down by an integration test against the real driver rather than by reading the code. When the distinction genuinely matters, do not infer: either read the row back, or make the statement itself unambiguous (for example by including a condition that changes on every write, like an updated-at column or a version number). ### The good use of the count: optimistic concurrency The same mechanism carries a stronger meaning when the `WHERE` clause includes a version: `UPDATE docs SET body = ?, version = version + 1 WHERE id = ? AND version = ?` Here `RowsAffected() == 0` means "somebody else wrote first", and that is a conflict the caller must be told about (`409`), not a not-found. The count is the entire concurrency-control signal, and dropping it silently loses updates. ### Deletes and batches `DELETE` behaves the same way: zero affected rows usually means the row was already gone, which for an idempotent delete endpoint is a success, not a 404 — a judgment call the API contract should make explicitly rather than leaving to whichever branch happened to be written first. For multi-statement scripts, what `RowsAffected` covers depends on the driver and on whether multiple statements are even permitted on one connection; do not assume it aggregates. And note that `sql.Stmt` and a transaction handle expose the same `ExecContext` shape and the same `sql.Result`, so everything above carries over unchanged. ### What an interviewer is listening for Three things: that `ExecContext` is the no-rows path and needs no `Close`; that `RowsAffected` returns an error that must be checked because the driver may not support it; and that mapping `0` to "not found" is a decision about the underlying database's counting semantics, not a universal truth.
- Why does RowsAffected return an error rather than just an int64?`sql.Result` is implemented by the driver, and not every database reports an affected-row count. Returning `(int64, error)` lets a driver say "I cannot answer" instead of returning a zero you would misread as "no row matched". The same reasoning applies to `LastInsertId`, which is unsupported wherever there is no single auto-increment concept.
- When is a RowsAffected count of zero not a not-found error?Two common cases. With optimistic concurrency — `UPDATE ... WHERE id = ? AND version = ?` — zero means somebody else wrote first, which is a conflict, not a missing row. And on a database that counts rows *changed* rather than *matched*, an update writing the value a row already holds reports zero even though the row exists.
- Why use ExecContext rather than QueryContext for an INSERT or UPDATE?`ExecContext` returns a `sql.Result`, so there is no `*sql.Rows` and no cursor holding a pooled connection — the connection is released as soon as the statement completes. Running a write through `QueryContext` gives you a result set you must remember to close, and forgetting leaks a connection for no benefit whatsoever.
saying these in an interview costs you the question
- Assumes a nil error from ExecContext means a row was updated
- Uses RowsAffected without checking the error it returns
- Believes every driver can report an affected-row count
- Says zero affected rows always means the row does not exist
- Runs an UPDATE through QueryContext and never closes the rows
- Treats LastInsertId as portable across all databases