The empty result trap
You're building a handler to display a user profile. You pass a database ID, run the query, and the code proceeds to render a page for a user with ID 0 and an empty name. The database didn't crash. The connection is fine. The query executed perfectly. The problem is that no user exists with that ID, and your code didn't check for that possibility. The error sql: no rows in result set is Go's way of stopping you from treating an empty result as a valid record.
Zero rows is data, not a failure
The database/sql package distinguishes between a query that fails and a query that returns nothing. A failure might be a syntax error, a connection drop, or a type mismatch. Returning zero rows is a successful execution with an empty result set.
QueryRow is designed for queries where you expect exactly one row. When you call Scan on the result, Go checks if a row is available. If the result set is empty, Scan returns the sentinel error sql.ErrNoRows. This isn't a panic. It's a return value you must handle. Ignoring it leaves your variables at their zero values, which creates subtle bugs that are hard to trace.
Think of QueryRow like asking a librarian for a specific book. If the librarian finds the book, they hand it to you. If the book doesn't exist in the catalog, the librarian doesn't throw the bookshelf at you. They hand you a slip of paper that says "Not Found." You have to read that slip and decide what to do. If you ignore the slip and assume you have the book, you'll try to read a blank page.
Zero rows is not a failure. It's data.
Minimal example: checking the sentinel
The pattern for handling a single-row query has two steps. First, check for sql.ErrNoRows. Second, check for any other error. The order matters. sql.ErrNoRows is an error, so err != nil evaluates to true. If you check the general error first, you'll treat a missing row as a fatal failure.
package main
import (
"database/sql"
"fmt"
"log"
)
func main() {
// Open a database connection. In real code, handle the error here.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create a table and insert a row for demonstration.
_, 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)
}
var id int
var name string
// QueryRow prepares the query. Scan executes it and fills the variables.
// Scan returns an error if no rows are found or if a database error occurs.
err = db.QueryRow("SELECT id, name FROM users WHERE name = $1", "Alice").Scan(&id, &name)
// Check for the specific "no rows" error first.
// This is a business case, not a system failure.
if err == sql.ErrNoRows {
fmt.Println("User not found. This is expected behavior, not a crash.")
return
}
// Check for other errors like connection issues or type mismatches.
// This catches real problems that need attention.
if err != nil {
log.Fatal(err)
}
// If we reach here, the scan succeeded and variables are populated.
fmt.Printf("Found user: ID %d, Name %s\n", id, name)
}
Check the error. Always check the error.
What happens under the hood
When you call QueryRow, Go doesn't send the query to the database yet. It returns a *sql.Row object that holds the query and parameters. The actual execution happens when you call Scan. This lazy evaluation allows Go to optimize the call.
When Scan runs, the driver sends the query to the database. The database processes the request and returns the result set. If the result set contains a row, Scan copies the data into the pointers you provided. If the result set is empty, Scan returns sql.ErrNoRows. The variables you passed to Scan remain unchanged. If you passed uninitialized variables, they stay at their zero values. An int stays 0. A string stays "". A pointer stays nil.
This is why checking the error is mandatory. Without the check, your code continues with garbage data. You might pass ID 0 to an update function, accidentally modifying the wrong record or crashing the database. The compiler won't stop you. The compiler only checks types. If you pass id instead of &id, the compiler rejects the code with cannot use id (type int) as *int value in argument. This is a helpful error. It forces you to pass a pointer so Scan can modify the value. But the compiler cannot check if the row exists. That's a runtime check.
The community accepts the verbose error checking because it makes the unhappy path visible. You can't accidentally ignore a missing row.
Realistic example: service layer with context
In production code, you'll wrap database calls in a service or repository. You'll use context.Context to handle cancellation and deadlines. You'll return custom errors to separate business logic from infrastructure failures.
package service
import (
"context"
"database/sql"
"errors"
"fmt"
)
// ErrUserNotFound indicates a user does not exist in the database.
var ErrUserNotFound = errors.New("user not found")
// User represents a user record.
type User struct {
ID int
Name string
}
// UserRepository handles database operations for users.
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new repository instance.
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// GetUserByID fetches a user by their ID.
// It returns ErrUserNotFound if the ID does not exist.
// It returns a wrapped error for database failures.
func (r *UserRepository) GetUserByID(ctx context.Context, id int) (User, error) {
var u User
// Use QueryContext to respect cancellation and deadlines.
// Context is always the first parameter in Go conventions.
// If the context is cancelled, the query stops and returns an error.
err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
if err == sql.ErrNoRows {
// Return a custom error for business logic.
// The caller can distinguish "not found" from "database down".
return User{}, ErrUserNotFound
}
if err != nil {
// Wrap the error to preserve the stack trace and add context.
// The %w verb allows callers to unwrap the error later.
return User{}, fmt.Errorf("get user by id %d: %w", id, err)
}
return u, nil
}
Wrap the error. Return the context.
Pitfalls and runtime surprises
The silent zero is the most dangerous bug. If you write db.QueryRow(...).Scan(&id) and ignore the error, id becomes 0. You might pass 0 to an update function, accidentally modifying the wrong record or crashing the database. The compiler won't stop you. The compiler only checks types. If you pass id instead of &id, the compiler rejects the code with cannot use id (type int) as *int value in argument. This is a helpful error. It forces you to pass a pointer so Scan can modify the value. But the compiler cannot check if the row exists. That's a runtime check.
Wrong scan types cause runtime panics or errors. Scan requires the Go type to match the database type. If the database returns a TEXT column and you scan into an int, you get a type conversion error. The error message looks like sql: Scan error on column index 1, name "name": converting driver.Value type []uint8 ("Alice") to a int: invalid syntax. This happens at runtime. Ensure your struct fields or variables match the schema.
Using Query for one row adds unnecessary complexity. Some developers use Query and iterate over rows.Next() even when they only want one row. This works, but it's verbose. You have to handle rows.Close(), check rows.Next(), and handle rows.Err(). QueryRow encapsulates all that boilerplate for the single-row case. Using Query for a single row adds complexity without benefit.
QueryRow blocks the goroutine until the result arrives. If the query hangs, your goroutine hangs. For short queries, this is fine. For complex reports, use QueryContext with a timeout. This prevents goroutine leaks. If a client disconnects from an HTTP server, you want the query to cancel. context.Context provides this mechanism.
Silent zeros are worse than loud errors. Check the error or pay later.
QueryRow in transactions
When you start a transaction with db.Begin(), you get a *sql.Tx. The Tx object has the same methods as DB, including QueryRow. You use tx.QueryRow instead of db.QueryRow to ensure the query runs within the transaction boundary. If you use db.QueryRow inside a transaction, the query might run outside the transaction, leading to race conditions or inconsistent reads. Always use the transaction object for queries that must be atomic.
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var count int
// Use tx.QueryRow to keep the query inside the transaction.
err = tx.QueryRow("SELECT COUNT(*) FROM orders WHERE user_id = $1", userID).Scan(&count)
if err != nil {
return err
}
// ... perform other operations ...
err = tx.Commit()
if err != nil {
return err
}
Trust the transaction object. Keep queries inside the boundary.
Decision: which query method to use
Use QueryRow when you expect exactly one row and want concise code for a single result.
Use Query when you expect zero, one, or many rows and need to iterate over the result set.
Use QueryContext when you need to cancel a long-running query or enforce a deadline via context.Context.
Use db.Exec when you are performing an insert, update, or delete and don't need to read rows back.
Match the tool to the shape of the data. Don't iterate a single row. Don't scan a result set.