How to Scan Query Results into Structs in Go

Use the Scan method with pointers to struct fields to map database query results to Go structs.

How to Scan Query Results into Structs in Go

You are building an API endpoint that returns user profiles. You hit the database, get a result set, and now you need that data in a Go struct so you can serialize it to JSON. The database speaks rows and columns. Go speaks types and values. Bridging that gap is the job of database/sql.Scan. You do not want to manually parse column indices or guess types. You want the data to land in your struct fields safely, with the runtime helping you catch mismatches.

Scan is the bridge between the database driver and your Go code. Database drivers return raw bytes and metadata. Scan takes those raw results, converts them to the Go types you specify, and writes the values into the memory addresses you provide. You pass pointers to your struct fields. Scan follows those pointers and fills in the values. The order of the pointers must match the order of the columns in your SELECT statement. If the types do not match or the order is wrong, Scan returns an error.

Think of Scan like a form-filling clerk. You hand the clerk a stack of envelopes labeled "ID", "Name", "Email". The clerk reads the database row left-to-right and writes the data into the envelopes. If you hand the envelopes in the wrong order, the ID goes into the Name envelope. The clerk does not check the labels; the clerk just follows the sequence. You are responsible for the order.

Minimal example

Here is the simplest scan: query one row, define a struct, pass pointers to the fields in column order.

type User struct {
	ID   int
	Name string
}

var u User
// QueryRow executes the query and returns a single row.
// Scan expects pointers to the destination variables.
err := db.QueryRow("SELECT id, name FROM users WHERE id = ?", 1).Scan(&u.ID, &u.Name)
if err != nil {
	// Scan returns an error if types mismatch or column count differs.
	log.Fatal(err)
}

QueryRow returns a *sql.Row. Calling Scan on that row triggers the execution if it hasn't happened yet. The arguments to Scan are ...any, which means you can pass any type. The compiler accepts the call as long as you pass pointers. The type checking happens at runtime when the driver tries to convert the database value to your Go type.

Go passes arguments by value. If you pass u.ID, the function receives a copy of the integer. Writing to that copy changes nothing in u. You must pass the address of the field. &u.ID gives Scan the location where the result should live. This is a fundamental Go pattern: functions that modify their arguments take pointers.

Scan is a contract. Keep the order, check the types, pass pointers.

Walkthrough: what happens under the hood

When you call Scan, several steps occur. database/sql is a standard library interface. It does not talk to the database directly. It talks to a driver. The driver implements the driver.Rows interface and handles the wire protocol. Scan is the contract between the standard library and the driver.

QueryRow borrows a connection from the pool, sends the SQL, and waits for the result. The driver fetches the first row and buffers it. When you call Scan, the standard library iterates over your arguments. For each argument, it checks if the argument implements sql.Scanner. If it does, Scan calls the Scan method on that type. If not, it dereferences the pointer and attempts a type conversion.

The driver provides the raw value, usually as a []byte or int64. The standard library tries to convert that raw value to the destination type. If the destination is a string and the driver returns []byte, the conversion succeeds. If the destination is an int and the driver returns []byte containing "abc", the conversion fails. The error propagates back to you.

If you forget to capture a loop variable or pass the wrong number of arguments, the compiler might not catch it. Scan takes a variadic list of any. The compiler sees a valid call. The crash happens at runtime. If you pass one argument for two columns, you get sql: expected 2 destination arguments in Scan, got 1. If you pass two arguments for one column, you get sql: expected 1 destination arguments in Scan, got 2.

The receiver name convention applies here too. If you wrap Scan in a helper, name the receiver u for User, not this or self. Go idioms favor short, meaningful receiver names.

Trust the pointer. If the value does not change, you passed the wrong address.

Realistic example: multiple rows and nulls

Real queries return multiple rows. Use Query to get a Rows object, iterate with Next, and scan inside the loop. Production code also uses context.Context for cancellation and handles nullable columns with sql.NullString or sql.NullInt64.

Here is a realistic scan loop with context, null handling, and proper error checking.

type User struct {
	ID    int
	Name  string
	Bio   sql.NullString
	Active bool
}

// FetchUsers retrieves all active users with a timeout.
func FetchUsers(ctx context.Context, db *sql.DB) ([]User, error) {
	// QueryContext respects the deadline and cancellation signal.
	rows, err := db.QueryContext(ctx, "SELECT id, name, bio, active FROM users WHERE active = $1", true)
	if err != nil {
		return nil, err
	}
	// Always close rows to release the connection back to the pool.
	defer rows.Close()

	var users []User
	for rows.Next() {
		var u User
		// Scan into the struct fields.
		// Bio is sql.NullString because the column allows NULL.
		if err := rows.Scan(&u.ID, &u.Name, &u.Bio, &u.Active); err != nil {
			return nil, err
		}
		users = append(users, u)
	}
	// Check for errors that occurred during iteration.
	// Next() returns false on error, so this catches network drops.
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return users, nil
}

QueryContext takes a context.Context as the first parameter. This is a Go convention. Functions that perform I/O should accept a context to support cancellation and deadlines. Name the parameter ctx. Pass it through every call site that might block.

rows.Close() releases the database connection. If you forget to close rows, the connection stays checked out. Eventually, the pool exhausts and your application hangs. defer rows.Close() is the standard pattern.

rows.Next() advances to the next row and returns true if a row is available. It returns false when there are no more rows. It also returns false if an error occurs. You must check rows.Err() after the loop to distinguish between "no more rows" and "something broke". If the network drops halfway through, Next() stops. Without rows.Err(), your code assumes the loop finished successfully and returns partial data.

sql.NullString handles database NULLs. A Go string cannot represent NULL. If you scan a NULL into a string, you get sql: Scan error on column index 2, name "bio": converting driver.Value type <nil> to type string: unsupported driver -> Scan pair. sql.NullString has two fields: String and Valid. If Valid is true, the value is present. If Valid is false, the database returned NULL.

Rows are streams. Close them, check errors, handle nulls.

Pitfalls and common errors

Scanning seems simple, but the runtime can surprise you. The compiler cannot verify the relationship between your SQL columns and your Go pointers. You get the errors when the code runs.

Order mismatch. If your query is SELECT name, id but you scan &u.ID, &u.Name, the name string lands in the ID integer. The driver tries to convert the string to an int and fails. You get sql: Scan error on column index 0, name "name": converting driver.Value type string ("Alice") to int: invalid syntax. The error message tells you the column name and the type mismatch. Fix the order in Scan or fix the order in SELECT. Explicit column lists in SELECT make the order obvious. Never use SELECT * in production code. Schema changes break SELECT * silently until a scan fails.

Type mismatch. Different databases return different types for the same concept. PostgreSQL might return int8 for a big integer. MySQL might return []byte for everything. The driver handles most conversions, but edge cases exist. If you scan a time.Time into a string, the driver might not know how to format it. You get sql: Scan error... unsupported driver -> Scan pair. Use the correct Go type. time.Time for timestamps. int64 for large integers. bool for booleans.

NULL values. This is the most common runtime panic source for beginners. Database columns default to allowing NULL unless you specify NOT NULL. Go variables have zero values. An int defaults to 0. A string defaults to "". If the database returns NULL and you scan into an int, you cannot distinguish between a stored 0 and a missing value. Worse, some drivers reject the scan entirely. Use sql.NullInt64, sql.NullString, sql.NullBool, or sql.NullTime for nullable columns. Check the Valid field before using the value.

Unused imports. If you use sql.NullString, you must import database/sql. If you forget, the compiler rejects the program with undefined: sql. If you import database/sql but do not use it, you get imported and not used. Go requires all imports to be used. This rule keeps code clean.

Connection leaks. If you return early from a function without closing rows, the connection leaks. Use defer rows.Close() immediately after checking the error from Query. If Query fails, rows is nil. Calling Close() on nil is safe in Go, but the idiom is to check the error first.

The worst scan bug is the one that corrupts data silently. Verify your column order against your struct fields.

Decision matrix

Use db.QueryRow and Scan when you expect exactly one row and want minimal overhead. Use db.Query with Rows.Next when you need to stream multiple rows without loading everything into memory at once. Use sql.NullInt64 or sql.NullString when a column allows NULL values and you need to distinguish between a missing value and a zero value. Use a third-party scanner like sqlx or lib/pq when you prefer struct tags over manual pointer lists and want automatic mapping by column name. Use explicit column lists in SELECT when you want to prevent breakage from schema changes and make the scan order obvious. Use context.Context as the first parameter when your query might block and you need cancellation support.

Scan is a contract. Keep the order, check the types, handle the nulls.

Where to go next