It is easy to look at a Go type such as sql.NullString or google/uuid.UUID, notice that it implements sql.Scanner and driver.Valuer, and conclude that every field in a database model needs those methods.
It does not.
The important distinction is that database/sql converts column values, not structs. A struct is usually just where your application groups the destinations passed to Scan. Its ordinary fields work without custom interfaces whenever database/sql and the selected driver already know how to convert them.
This article builds a practical model for deciding when the interfaces are useful, when they are unnecessary, and where conversion behavior should live.
TL;DR
database/sqldoes not automatically map a row into a struct.- You normally pass pointers to individual fields:
row.Scan(&user.ID, &user.Email). - Built-in scalar types, byte slices,
time.Time, nullable pointers, and some specially supported standard types require no custom methods. sql.Scannercontrols reads from one database column into a Go value.driver.Valuercontrols writes from one Go value into a query parameter.- Implement both for a custom type that owns a non-trivial database representation.
- Do not implement them on a row struct unless the entire struct is stored in a single column, such as JSON.
- Driver behavior can extend the generic
database/sqlrules, so integration tests still matter.
1) A struct is not scanned automatically
Consider a normal application model:
type User struct { ID uuid.UUID Email string DisplayName *string CreatedAt time.Time DeletedAt *time.Time}With the standard database/sql package, the query does not know that column names correspond to these fields. You provide the mapping explicitly and positionally:
func findUser(ctx context.Context, db *sql.DB, id uuid.UUID) (User, error) { const query = ` SELECT id, email, display_name, created_at, deleted_at FROM users WHERE id = ? `
var user User err := db.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.DeletedAt, ) if err != nil { return User{}, fmt.Errorf("find user: %w", err) } return user, nil}The five destination arguments correspond to the five selected columns. Rows.Scan performs conversions into those destinations and reports an error when a conversion would lose information or the destination is unsupported.
[1]The Go Team. “Rows.Scan.” Go standard library documentation.
Libraries such as sqlx, generated query layers, and ORMs can add name-based or tag-based struct mapping. That is library behavior layered above database/sql; it does not change the conversion contract of each field.
Actionable insight: Keep the selected column order next to the Scan call, or use generated code when queries become numerous. Adding db tags to a struct does nothing by itself when using only database/sql.
2) Types that usually work without custom interfaces
Database drivers exchange a deliberately small set of values with database/sql. Common inputs and outputs include integers, floating-point values, booleans, strings, byte slices, timestamps, and nil. database/sql then performs a documented set of assignments and safe conversions.
[1]The Go Team. “driver.Value.” Go standard library documentation.
| Go field | Typical SQL value | Custom interfaces? |
|---|---|---|
string |
text, varchar | Usually no |
int64, float64, bool |
numeric and boolean columns | Usually no |
[]byte |
binary data or raw text | Usually no |
time.Time |
timestamp | No with normal drivers |
*string, *time.Time |
nullable text or timestamp | No; nil represents SQL NULL |
sql.Null[T] |
nullable scalar with explicit validity | No; the standard wrapper implements both interfaces |
uuid.UUID in Go 1.27 |
UUID text or 16-byte value | No; database/sql has explicit support |
Defined scalar types often work too:
type UserRole string
type RetryCount int64When the driver returns a compatible value, database/sql can convert a string into UserRole or an integer into RetryCount. The methods become useful when you want validation or a different wire representation, not merely because the types have names.
Actionable insight: Start with ordinary field types. Add conversion methods only when the default behavior is insufficient or when the type needs to enforce an invariant at the database boundary.
3) The two interfaces describe opposite directions
sql.Scanner handles values coming from the database into Go:
type Scanner interface { Scan(src any) error}The method belongs on a pointer receiver because it changes the destination. The source is normally nil, an integer, a float, a boolean, []byte, a string, or time.Time, depending on the driver and column.
[1]The Go Team. “sql.Scanner.” Go standard library documentation.
driver.Valuer handles a value going from Go into the database:
type Valuer interface { Value() (driver.Value, error)}It converts the custom Go value into something the driver accepts as a query argument. [1]The Go Team. “driver.Valuer.” Go standard library documentation.
The interfaces are independent. A read-only destination might need only Scanner; a write-only parameter might need only Valuer. Domain types that regularly travel in both directions usually implement both so their representation remains symmetric.
Compile-time assertions make that intent visible:
var _ sql.Scanner = (*Preferences)(nil)var _ driver.Valuer = Preferences{}Actionable insight: Think “decode” for Scanner and “encode” for Valuer. Implementing one does not automatically solve the opposite direction.
4) Add interfaces when a scalar type owns validation
A named string does not need interfaces merely to move through a text column. It may deserve them when invalid database values must never enter the application.
type UserRole string
const ( RoleAdmin UserRole = "admin" RoleMember UserRole = "member")
func parseUserRole(value string) (UserRole, error) { switch UserRole(value) { case RoleAdmin, RoleMember: return UserRole(value), nil default: return "", fmt.Errorf("invalid user role %q", value) }}
func (r *UserRole) Scan(src any) error { if r == nil { return errors.New("UserRole.Scan called on nil receiver") }
var value string switch src := src.(type) { case string: value = src case []byte: value = string(src) case nil: return errors.New("user role cannot be NULL") default: return fmt.Errorf("cannot scan %T into UserRole", src) }
parsed, err := parseUserRole(value) if err != nil { return err } *r = parsed return nil}
func (r UserRole) Value() (driver.Value, error) { parsed, err := parseUserRole(string(r)) if err != nil { return nil, err } return string(parsed), nil}This does more than conversion: it makes the database boundary enforce the same invariant as the rest of the domain model.
Notice the explicit handling of string, []byte, and nil. Drivers do not all return textual columns in exactly the same representation, so a robust scanner handles the representations it intends to support and rejects the rest clearly.
Actionable insight: Put validation in a shared parser and call it from both methods. That prevents reads and writes from gradually accepting different value sets.
5) Store a structured value in one JSON column
The interfaces are most clearly justified when one SQL column contains a structured application value.
type Preferences struct { Theme string `json:"theme"` EmailUpdates bool `json:"email_updates"` ItemsPerPage int `json:"items_per_page"`}
func (p *Preferences) Scan(src any) error { if p == nil { return errors.New("Preferences.Scan called on nil receiver") } if src == nil { *p = Preferences{} return nil }
var data []byte switch src := src.(type) { case []byte: data = src case string: data = []byte(src) default: return fmt.Errorf("cannot scan %T into Preferences", src) }
var decoded Preferences if err := json.Unmarshal(data, &decoded); err != nil { return fmt.Errorf("decode preferences: %w", err) } *p = decoded return nil}
func (p Preferences) Value() (driver.Value, error) { data, err := json.Marshal(p) if err != nil { return nil, fmt.Errorf("encode preferences: %w", err) } return data, nil}
var _ sql.Scanner = (*Preferences)(nil)var _ driver.Valuer = Preferences{}Now Preferences can be used as a destination and as a query argument:
var preferences Preferenceserr := row.Scan(&preferences)
_, err = db.ExecContext( ctx, "UPDATE users SET preferences = ? WHERE id = ?", preferences, userID,)The type owns its database representation, so every caller gets the same JSON encoding, null policy, and error context.
One caution from the Scanner contract: reference values such as []byte supplied by a driver may only remain valid until the next call to Scan. Decode or copy anything that must survive the call; do not retain the driver’s slice directly.
[1]The Go Team. “sql.Scanner source-value lifetime.” Go standard library documentation.
Actionable insight: Implement the interfaces on a structured type when the whole value belongs to one column. Make the NULL behavior explicit—zero value, pointer, validity flag, or error—rather than accepting it accidentally.
6) Null is a domain decision
SQL NULL is not the same as a Go zero value. Choose a representation based on whether the distinction matters to the application.
Use pointers for a simple optional value
type User struct { DisplayName *string DeletedAt *time.Time}This is compact and works naturally with database/sql. It also tends to serialize clearly through JSON when paired with appropriate tags.
Use sql.Null[T] when validity should be explicit
type UserRow struct { DisplayName sql.Null[string] DeletedAt sql.Null[time.Time]}The Valid field distinguishes NULL from a present zero value without pointer dereferencing. This can be useful in persistence-specific structs, although exposing SQL wrapper types throughout the domain layer creates coupling.
Create a domain-specific nullable type when semantics are richer
If “missing,” “unknown,” and “empty” have different business meanings, a custom type with Scanner and Valuer can encode that policy. Do this for semantics, not merely to avoid pointers.
Actionable insight: Decide what NULL means before choosing the Go type. Pointer, sql.Null[T], and a domain wrapper are different modeling choices, not interchangeable syntax preferences.
7) Go 1.27 UUIDs are a useful exception
The new standard-library UUID package illustrates why interface inspection alone can be misleading. Its UUID type does not implement sql.Scanner or driver.Valuer, yet Go 1.27 supports it directly inside database/sql:
- Query parameters of type
uuid.UUIDare converted to strings. - Database strings and byte slices scan into
*uuid.UUID. *uuid.UUIDworks naturally for nullable columns.
The motivation and comparison with the previous de facto standard, github.com/google/uuid, are covered in the Go 1.27 release notes.
[1]The Go Team. “database/sql UUID conversion.” Go source code, 2026.
[1]The Go Team. “database/sql/driver UUID conversion.” Go source code, 2026.
There is an important type-identity edge case:
// Alias: retains the standard UUID type and its database/sql support.type AccountIDAlias = uuid.UUID
// Defined type: a new type whose underlying representation is [16]byte.type AccountID uuid.UUIDThe defined type is not exactly uuid.UUID, so the standard library’s concrete UUID conversion does not apply. You must convert at query boundaries or implement the interfaces on the new type. An alias keeps the original type identity but cannot have its own methods.
Actionable insight: Use a type alias when you only want domain vocabulary. Use a defined type when you need separate behavior, and accept responsibility for its database conversion.
8) Should the row struct implement the interfaces?
Usually, no.
This does not make database/sql scan several columns into a User:
func (u *User) Scan(src any) errorScanner receives the value of one column, not an entire row. A query returning five columns still needs five destinations. Implementing Scanner on User only makes sense if one column contains a complete serialized user, such as JSON, a database composite value, or another driver-specific representation.
Likewise, driver.Valuer converts one query argument into one driver value. It does not expand a struct into several placeholders.
// Valuer cannot turn this single argument into three SQL parameters.db.Exec("INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)", user)Pass the fields individually, use a query generator, or let an ORM perform the struct expansion.
Actionable insight: Put conversion methods on the smallest type that corresponds to one column. Keeping that boundary narrow makes schemas, errors, and reuse easier to reason about.
9) A decision guide
Ask these questions for each field:
- Does one Go value correspond to one SQL column? If not,
ScannerandValuerare probably the wrong abstraction. - Does
database/sqlor the driver already support the type? Start without custom methods. - Is the type a named scalar with the same representation? Default conversion may already work.
- Must reads validate or decode data? Implement
Scanner. - Must writes validate or encode data? Implement
driver.Valuer. - Can the column be
NULL? Choose pointer,sql.Null[T], or a domain-specific policy. - Is the behavior driver-specific? Verify it against the real driver and database, not only a mock.
Unit-test the methods with every source form you accept—especially nil, string, and []byte—then add one integration test covering a real round trip through the production driver.
The useful mental model
A “database-compatible struct” is rarely a struct with database interfaces. It is a struct whose fields have clear column representations, whose query code maps columns explicitly, and whose custom types own only the conversions they genuinely need.
Start with ordinary values. Use pointers or sql.Null[T] for nullability. Add Scanner when reading requires custom decoding or validation, and add Valuer when writing requires custom encoding or validation. If a standard package or driver already provides the conversion—as Go 1.27 does for UUIDs—use it instead of recreating the same behavior on every type.