Right, let’s get to the part where you actually get your data out of those query results and into something useful in your Go program. This is where most people’s first encounters with database/sql go from “oh, this is easy” to a guttural “oh, come on.” I’m here to save you the headache.

You’ve executed your Query or QueryContext and you’re holding a *sql.Rows object. Think of this Rows object as a slightly rude museum attendant pointing a flashlight at one row of data at a time. It’s your job to look at the current row, scribble down what you see (Scan it), and then tell the attendant to move to the next one. You do this in a loop until they tell you there’s nothing left to see.

The absolute bedrock method here is rows.Scan(). You provide it with a list of pointers—one for each column your query returns—and it will dutifully, and somewhat magically, convert the underlying driver’s data types into your Go types. It’s like a bouncer checking IDs at a club, making sure the data gets into the right variable.

rows, err := db.Query("SELECT id, name, email FROM users WHERE active = ?", true)
if err != nil {
    log.Fatal(err)
}
defer rows.Close() // This is not a suggestion. Do it. I'll explain why later.

for rows.Next() {
    var id int
    var name, email string
    err := rows.Scan(&id, &name, &email)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("User %d: %s (%s)\n", id, name, email)
}

// Don't just assume the loop ended happily! Check for errors.
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

The Non-Negotiable defer rows.Close()

I told you to do it. Here’s why: a Rows object isn’t just an iterator; it’s a live connection to your database. Until you Close() it, that connection is not returned to the pool. If your function has a few million other things to do after the query and you forget to close the rows, you’re holding that connection hostage. Do this enough times and your connection pool will be exhausted, leading to deadlocks where everything is just waiting for a free connection. defer is your insurance policy against this. It guarantees the connection gets released back to the pool when the function exits, even if you hit an error mid-loop.

The Order of Operations is Everything

Notice how in rows.Scan(&id, &name, &email) the variables match the exact order of the columns in your SELECT statement. If your query was SELECT email, name, id..., you’d scan into &email, &name, &id. The database/sql package is brutally literal here. It doesn’t care about column names, only their position. Get this wrong and you’ll get a type conversion error or, worse, silently scan gibberish into the wrong variable. This is the most common foot-gun in this whole process.

Handling Nullable Columns Gracefully

Here’s where the designers’ choices become… questionable. What if your name column allows NULL values? Scanning a NULL into a string will cause a fatal error. string can’t be nil. This is a classic “impedance mismatch” between SQL’s world and Go’s.

The solution is to use the sql package’s nullable types, like sql.NullString. It’s a bit clunky, but it works.

type User struct {
    ID    int
    Name  sql.NullString
    Email string
}

// ... later, in your rows loop ...
var u User
err := rows.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
    log.Fatal(err)
}

// You have to check if the value is valid before using it.
if u.Name.Valid {
    fmt.Println("Name is", u.Name.String)
} else {
    fmt.Println("Name is NULL")
}

Yes, it’s verbose. Yes, it makes your structs uglier. No, there’s no way around it if you have nullable fields. You can write scanner interfaces or use more complex ORMs to hide this, but at this level, this is the reality. Embrace the clunk.

The Final Check: rows.Err()

Your for rows.Next() loop breaks when there are no more rows or if an error occurs during fetching. How do you know which one happened? You check rows.Err() after the loop. Never skip this. If you do and an error occurred, you might just process a partial dataset and blissfully continue, creating a silent failure that’s a nightmare to debug. Always, always check the error after the loop. It’s the difference between “we’re done” and “something went horribly wrong mid-stream.”