The error is a signal, not a crash
You write a handler to fetch a user by ID. You run the query. The database returns nothing because the user doesn't exist. Your code hits sql: no rows in result set. You stare at the error and wonder why Go is yelling at you for a perfectly normal situation: a missing record.
The error isn't a crash. It's a sentinel error value returned by Scan. The database/sql package uses sql.ErrNoRows to tell you that the query executed successfully but found zero rows. This happens when you use QueryRow or Row.Scan and the result set is empty. The fix is to check for this specific error and handle the missing data gracefully.
How QueryRow and Scan work together
QueryRow is designed for queries that should return exactly one row. It sends the SQL to the driver and waits for a single result. Scan reads that result and copies the column values into your Go variables.
If the database returns a row, Scan copies the data and returns nil. If the database returns nothing, Scan returns sql.ErrNoRows. If something goes wrong with the connection or the query syntax, Scan returns a different error.
This pattern makes the happy path and the missing-data path explicit. You don't get a silent failure. You get a value you can check.
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// Open an in-memory database for a self-contained example.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create a table and insert one row.
_, err = db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
if err != nil {
log.Fatal(err)
}
_, err = db.Exec("INSERT INTO users (name) VALUES ('alice')")
if err != nil {
log.Fatal(err)
}
// QueryRow expects one row. Scan copies data into variables.
var id int
err = db.QueryRow("SELECT id FROM users WHERE name = ?", "alice").Scan(&id)
if err == sql.ErrNoRows {
fmt.Println("User not found")
} else if err != nil {
log.Fatal(err)
} else {
fmt.Printf("Found ID: %d\n", id)
}
// A missing row triggers sql.ErrNoRows, not a panic.
err = db.QueryRow("SELECT id FROM users WHERE name = ?", "bob").Scan(&id)
if err == sql.ErrNoRows {
fmt.Println("Bob is missing")
} else if err != nil {
log.Fatal(err)
}
}
sql.ErrNoRows is a value you check, not a panic you catch.
Walkthrough: what happens under the hood
When you call QueryRow, Go sends the SQL to the driver. The driver talks to the database. If the database finds a row, the driver buffers it. Scan reads the buffer and copies values into your variables.
If the database finds nothing, the driver tells QueryRow there are no rows. Scan sees this and returns sql.ErrNoRows. This isn't a compiler error. It's a runtime value. Your code continues running. You decide what to do with the missing data.
If you forget to pass a pointer to Scan, the compiler stops you. Scan needs to write data into your variables. Go passes arguments by value. If you pass id, Scan gets a copy of the integer. It updates the copy, and the update vanishes. Passing &id gives Scan the memory address. It writes directly to your variable.
The compiler rejects the program with cannot use id (variable of type int) as *int value in argument to Scan if you drop the &. This error saves you from a bug where Scan appears to succeed but your variable stays zero.
Why Scan needs pointers
Go functions receive copies of arguments. To modify a variable from inside a function, you must pass a pointer. Scan modifies your variables to fill them with database data. It requires pointers so it can write to the original memory.
This rule applies to every argument in Scan. If you have three columns, you pass three pointers. The order of pointers must match the order of columns in the SELECT statement.
If the counts don't match, the driver returns an error like sql: expected 3 destination arguments in Scan, got 2. The error tells you exactly how many arguments you provided versus how many the query returned. Count your columns and your pointers. They must align.
Handling NULL values
NULL values cause a different class of error. If a column allows NULL and the row has NULL, Scan cannot put that into a plain string or int. The database has no value to copy. A Go string cannot represent "no value".
If you scan a NULL into a plain string, the driver rejects the operation with an error about converting NULL to a Go string. Use sql.NullString, sql.NullInt64, or sql.NullBool for columns that might be NULL. These types have a Valid boolean and a typed field. If Valid is false, the database returned NULL.
// FetchProfile handles nullable email addresses.
func (s *Service) FetchProfile(ctx context.Context, id int) (string, error) {
var email sql.NullString
// Scan into NullString to handle database NULL values safely.
err := s.db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", id).Scan(&email)
if err == sql.ErrNoRows {
return "", fmt.Errorf("user %d: %w", id, sql.ErrNoRows)
}
if err != nil {
return "", err
}
// Check Valid before accessing the String field.
if !email.Valid {
return "", nil
}
return email.String, nil
}
Pointers give Scan a place to write. Null types give your code a way to handle absence.
Realistic example: HTTP handler with context
In production code, you wrap database calls in functions that accept context.Context. The context carries deadlines and cancellation signals. If the client disconnects, the context cancels, and the query stops. This prevents goroutines from hanging on slow queries.
The convention is to pass ctx as the first parameter, named ctx. Functions that take a context should respect cancellation. Use QueryRowContext instead of QueryRow to thread the context through to the driver.
Error wrapping adds context to failures. Use fmt.Errorf with %w to wrap the original error. This preserves the error chain. Callers can use errors.Is to check for sql.ErrNoRows even after wrapping.
// GetUserByID retrieves a user by ID.
// It returns sql.ErrNoRows if the user is missing.
func (s *Service) GetUserByID(ctx context.Context, id int) (*User, error) {
var user User
// QueryRowContext respects cancellation from the caller.
err := s.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id).Scan(
&user.ID,
&user.Name,
&user.Email,
)
// Check for the specific "no rows" sentinel error.
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user %d: %w", id, sql.ErrNoRows)
}
// Wrap other errors with context.
if err != nil {
return nil, fmt.Errorf("query user %d: %w", id, err)
}
return &user, nil
}
The receiver name is usually one or two letters matching the type, like s for Service. This keeps the code compact and readable. The community follows this style consistently.
Context travels down. Errors bubble up. Wrap them both.
Pitfalls and compiler errors
Using Query instead of QueryRow changes the behavior. Query returns a *sql.Rows. You must call Next() to advance to the first row. If you forget Next(), Scan fails because there is no current row. The driver returns an error indicating no row is available.
If you use QueryRow for a query that returns multiple rows, you only get the first one. The driver discards the rest. This is efficient for single-row lookups but wrong for lists.
Type mismatches cause runtime errors. If the database returns an integer and you scan into a string, the driver tries to convert it. Some drivers handle this conversion. Others reject it. Check your driver documentation. When in doubt, scan into the matching Go type.
Goroutine leaks happen when you start a goroutine that waits on a channel or database call without a cancellation path. Always pass context to long-lived operations. If the caller cancels, the operation should stop and return.
The worst goroutine bug is the one that never logs.
Decision matrix
Use QueryRow when you expect exactly one row and want a clean error if it's missing.
Use Query with Rows.Next when you expect zero, one, or many rows and need to iterate over the result set.
Use Exec when you are inserting, updating, or deleting data and don't need to read the result set.
Use QueryRowContext when your query might run long and needs cancellation support from the caller.
Use sql.NullString when a column allows NULL and you need to distinguish between an empty string and a missing value.
Use errors.Is(err, sql.ErrNoRows) when checking for the no-rows error in wrapped error chains.
QueryRow for one. Query for many. Exec for changes.