The NULL panic
You write a query to fetch a user's nickname. The database returns a row. Your code crashes with a panic about a nil pointer dereference, or the scan fails with a type mismatch. You look at the data: the nickname column is empty. In SQL, empty isn't zero. Empty isn't an empty string. Empty is NULL, and Go doesn't know what to do with it.
Go has no NULL type. Go has zero values. A string zero value is "". An int zero value is 0. A pointer zero value is nil. NULL is different. NULL means the value is missing, unknown, or not applicable. An empty string is a value. It means the user has no nickname, but the nickname field exists. NULL means the nickname field has no data at all. Go refuses to conflate the two. If you try to scan a SQL NULL into a plain string, the driver rejects the operation because it cannot convert "no value" into a string value.
Wrappers carry the truth
The database/sql package solves this with wrapper types. sql.NullString, sql.NullInt64, sql.NullFloat64, and sql.NullTime wrap the underlying value and add a Valid boolean field. The wrapper implements the sql.Scanner interface, which the database driver uses to populate data from a query result.
When the driver scans a row, it calls the Scan method on your destination. If the destination is a sql.NullString, the wrapper checks whether the source is NULL. If the source is NULL, the wrapper sets Valid to false and leaves the String field as its zero value. If the source has data, the wrapper sets Valid to true and stores the string. You always check Valid before using the value.
The wrapper makes the absence of data explicit. You cannot accidentally treat NULL as an empty string because the type forces you to acknowledge the Valid flag. The String field is safe to read even when Valid is false; it returns "". The danger is logic, not a crash. If you skip the check, your code runs but produces wrong results.
Minimal example
Here's the simplest way to handle a nullable column. You scan into a sql.NullString and check the Valid field before accessing the underlying string.
package main
import (
"database/sql"
"fmt"
)
func main() {
// Assume db is a valid *sql.DB connection
var name sql.NullString
// Scan populates the wrapper; Valid becomes true only if the column is not NULL
err := db.QueryRow("SELECT nickname FROM users WHERE id = 1").Scan(&name)
if err != nil {
fmt.Println("Query failed:", err)
return
}
// Access the underlying string only when Valid is true to avoid using a zero value
if name.Valid {
fmt.Println("Nickname:", name.String)
} else {
fmt.Println("No nickname set")
}
}
The sql.NullString type has two exported fields: String and Valid. The String field holds the data. The Valid field tracks whether the database returned a value. This pattern applies to all the sql.Null* types. sql.NullInt64 has Int64 and Valid. sql.NullTime has Time and Valid.
How Scan works under the hood
Understanding the sql.Scanner interface clarifies why the wrapper works. The interface has a single method:
type Scanner interface {
Scan(src interface{}) error
}
When you call Scan on a *sql.Rows or *sql.Row, the driver iterates over the destinations you passed. For each destination, it checks if the type implements sql.Scanner. If it does, the driver calls Scan(src) where src is the raw value from the database. For a NULL column, src is nil.
The sql.NullString implementation looks roughly like this:
func (n *NullString) Scan(value interface{}) error {
// If value is nil, the database returned NULL
if value == nil {
n.String = ""
n.Valid = false
return nil
}
// Otherwise, convert the value to a string and mark it valid
n.String, n.Valid = value.(string), true
return nil
}
The driver passes nil for NULL. The wrapper catches nil, sets Valid to false, and returns no error. This is why scanning NULL into a NullString never fails. The wrapper is designed to absorb NULL gracefully. If you scan into a plain string, the driver tries to convert nil to string, which fails. The driver returns an error like sql: Scan error on column index 2, name "bio": converting NULL to string is unsupported.
The wrapper shifts the responsibility from the driver to your code. The driver says "I got NULL." The wrapper says "I stored that NULL safely. You decide what to do with it."
Realistic example with context
Real code rarely checks one field in isolation. You usually map a row to a struct. Here's a pattern for a user profile where the bio and avatar URL might be missing. The function takes a context.Context as the first parameter, following Go convention. The context allows the caller to cancel the query or set a deadline.
package main
import (
"context"
"database/sql"
)
type User struct {
ID int64
Name string
Bio sql.NullString
AvatarURL sql.NullString
}
// GetUser fetches a user by ID, handling nullable columns with sql.Null* types.
func GetUser(ctx context.Context, db *sql.DB, id int64) (*User, error) {
var u User
// Scan into the struct fields directly; NullString handles the NULL check internally
err := db.QueryRowContext(ctx,
"SELECT id, name, bio, avatar_url FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Bio, &u.AvatarURL)
if err != nil {
return nil, err
}
// Return the pointer to the struct so the caller can check for errors before dereferencing
return &u, nil
}
The struct fields Bio and AvatarURL use sql.NullString. This allows the struct to represent optional database columns accurately. When you scan, the driver calls Scan on each field. NullString implements sql.Scanner, so the scan succeeds even if the columns are NULL. The caller receives a User where Bio.Valid is false if the bio is missing.
The context.Context parameter goes first, named ctx. This is a Go community convention. Functions that perform I/O or long-running operations accept a context as the first argument. The context propagates cancellation signals and deadlines. The QueryRowContext method respects the context. If the context is cancelled, the query stops.
Pitfalls and silent bugs
The most common mistake is scanning a NULL into a plain type. If you define a struct with Bio string and scan a NULL column into it, the driver returns an error. The error message varies by driver but usually mentions converting NULL to string is unsupported. You must handle the error or the program stops.
A subtler mistake is using a pointer type like *string for scanning. Some developers think a pointer can represent NULL because nil is a valid pointer value. The database/sql driver behavior with pointers is inconsistent. Some drivers set the pointer to nil for NULL. Others panic. Others set the pointer to a zero value. Relying on pointer semantics for NULL handling is fragile. Use sql.NullString instead. The wrapper is explicit and works across all drivers.
Another pitfall is forgetting to check Valid. The String field is always accessible. If Valid is false, String is "". If you use String without checking Valid, you treat NULL as an empty string. This causes silent logic bugs. For example, if you concatenate the bio into a message, NULL becomes an empty string and the message looks correct. But if your logic distinguishes between "no bio" and "empty bio", the distinction is lost. Always check Valid when the semantic difference matters.
The compiler won't save you from a silent logic bug. Check Valid or lose the distinction.
Decision matrix
Use sql.NullString when the column can be NULL and you need to distinguish between an empty string and missing data. Use sql.NullInt64 or sql.NullFloat64 for nullable numeric columns where zero is a valid value distinct from NULL. Use sql.NullTime for nullable timestamps; the Valid field tells you if the date exists. Use a plain string or int when the column is defined as NOT NULL in the schema; the extra wrapper adds noise without benefit. Use a custom sql.Scanner implementation when you have a domain type that needs special parsing logic for NULL values. Use a pointer type like *string only when you are writing a custom scanner or interacting with a library that explicitly requires pointer semantics for optionality.
Match the Go type to the schema constraint. NULL columns get wrappers. NOT NULL columns get values.