SQL columns can be NULL, but Go primitives like string, int64, and float64 cannot. Scanning a NULL straight into a plain string returns a runtime error from database/sql. This chapter covers the three ways Go gives you to represent a nullable column: the legacy sql.NullString family, the Go 1.22 generic sql.Null[T], and plain pointers, with guidance on which to pick for which job.
Consider a customers table where phone is optional. Some rows have a phone number, others store NULL. The schema is the same one we'll use throughout the chapter.
The driver reads the row fine, but Scan refuses to put a NULL into a *string because Go has no way to represent "this string is absent" using the string type alone. The empty string "" is a real value, not a missing one, so database/sql won't conflate them.
That refusal is correct behavior, not a bug. You have to opt into one of the nullable representations. There are three options, each with different trade-offs.
The classic fix is the sql.Null* family. Each is a struct with two fields: a typed value and a Valid boolean that's true if the column had a non-NULL value.
| Type | Value Field | SQL Type |
|---|---|---|
sql.NullString | String string | TEXT, VARCHAR, CHAR |
sql.NullInt64 | Int64 int64 | BIGINT, INTEGER |
sql.NullInt32 | Int32 int32 | INT, INTEGER |
sql.NullInt16 | Int16 int16 | SMALLINT |
sql.NullByte | Byte byte | TINYINT (unsigned) |
sql.NullFloat64 | Float64 float64 | FLOAT, DOUBLE, REAL |
sql.NullBool | Bool bool | BOOLEAN, BIT |
sql.NullTime | Time time.Time | TIMESTAMP, DATETIME |
The rule is the same for every one: scan into the struct, then check Valid before reading the value.
The pattern reads as: scan into the wrapper, branch on Valid, then dereference the typed field if Valid is true. If you skip the Valid check and read phone.String directly, you'll get "" for the NULL row, which silently merges "absent" and "empty" again.
Cost: sql.NullString is a small two-field struct, 24 bytes on a 64-bit machine. Scanning into it doesn't allocate. The cost only shows up when you marshal it to JSON, which we'll cover later in this chapter.
The Valid bool design is verbose but explicit. There's no way to forget that the value might be missing, because you can't read the typed field without going through the wrapper.
Go 1.22 added a generic version that collapses the whole family into a single type: sql.Null[T]. The struct is the same shape, with a V T field and a Valid bool field, but the type parameter T lets you use one type for any scannable value.
Same scan flow, fewer named types to remember.
The only differences from the sql.NullString version are the declaration (sql.Null[string] instead of sql.NullString) and the value-field name (V instead of String). Everything else is identical.
Two notes on sql.Null[T]:
T is automatically scannable. The wrapper delegates to the driver's normal scan rules for T, so sql.Null[string], sql.Null[int64], sql.Null[float64], sql.Null[bool], and sql.Null[time.Time] all work the way you'd expect. Custom types need to implement sql.Scanner to be useful inside sql.Null[T].For new code on Go 1.22+ that needs to be uniform across many column types, sql.Null[T] is the cleaner pick. For libraries that must support older Go versions, stick with the named types.
There's a third option that doesn't use a wrapper at all: scan into a pointer. database/sql treats a nil pointer as NULL and a non-nil pointer as the value it points at.
Notice that the call is rows.Scan(&name, &phone), which passes a **string (address of the pointer). database/sql either sets *phone = nil (NULL) or allocates a new string and sets *phone = &"+91-9000000000"-like value (not NULL).
The pointer approach reads more naturally for many people: nil means absent, anything else means present. There's no Valid field, no wrapper type, and no awkward phone.String access. JSON encoding also falls out cleanly, which we'll see in a moment.
Cost: Every non-NULL row allocates a new value on the heap to hold the string. Across millions of rows in a tight loop, that adds GC pressure compared to scanning into a sql.NullString (no allocation per row). For most application code this difference is irrelevant; for bulk pipelines processing tens of millions of rows, it can matter.
The other gotcha is that nil checks are easy to forget. If your code does *phone without checking, you get a runtime panic instead of the empty-string fallback you'd get from sql.NullString. The compiler doesn't help you here; the Valid check on the struct types is at least visible at the type level.
Reading NULLs is half the story. To insert or update a NULL, you have to tell the driver to send NULL, which means passing one of these as an argument:
nil value of an interface or pointer type.sql.NullString{} (where Valid is false).Valid: false.When Valid is false, the wrapper's typed field is ignored. The driver looks only at Valid to decide whether to send NULL or the value. This means sql.NullString{Valid: false, String: "ignored"} and sql.NullString{} both produce SQL NULL.
For pointer-style code, the rule is even simpler: pass a *string and the driver sends NULL if it's nil, the dereferenced value otherwise.
Passing an untyped nil works too, as the driver treats it as NULL for any column type. A typed nil like (*string)(nil) works for the same reason.
This is where the choice of representation starts to matter for real APIs. When you serve customer rows over an HTTP endpoint, the JSON shape you produce depends on which nullable type you chose.
The pointer version is the only one that produces idiomatic JSON. The struct types leak their internal Valid field into the wire format, which clients have to parse around. A frontend expecting "phone": "+91-9000000000" or "phone": null will be unhappy with "phone": {"String": "+91-9000000000", "Valid": true}.
If you're building an HTTP API directly from your database row structs, pointers are usually the right pick. If your row structs are internal and you map them to API DTOs before serialization, any of the three work, but you have to do the conversion.
The diagram below summarizes how each approach behaves at the two boundaries: scanning from SQL and marshaling to JSON.
The pointer path (cyan) flows through the JSON boundary unchanged. The wrapper paths (orange) carry their internal structure through to the wire.
If you want to keep sql.NullString in your row struct (for scan ergonomics or compatibility with existing code) but emit pointer-style JSON, define a wrapper type with its own MarshalJSON and UnmarshalJSON methods.
The wrapper type is structurally identical to sql.NullString, so the database scan path keeps working. The custom marshaling is what makes the wire format clean. The same trick generalizes to the other sql.Null* types, and you can do it once in a shared package to use across an entire codebase.
A slight wrinkle: this NullableString won't automatically implement sql.Scanner and driver.Valuer, because those methods come from the original sql.NullString and don't transfer through the type alias. If you need it to be scannable too, either embed sql.NullString instead of aliasing it, or add the two methods explicitly.
Which of the three approaches should you use? Here's a side-by-side comparison.
| Approach | Min Go Version | Scan Ergonomics | JSON Output | Allocations | Best For |
|---|---|---|---|---|---|
sql.NullString, sql.NullInt64, etc. | Any (since Go 1.0 era) | Verbose; one named type per SQL type | Object with Valid/value fields | None per row | Library code that must support old Go versions, internal types not exposed over HTTP |
sql.Null[T] | 1.22+ | Verbose; uniform across types | Object with Valid/V fields | None per row | New code on modern Go, internal types where you want consistency without one type per column kind |
*string, *int64, etc. | Any | Clean; one type per column kind | Idiomatic value or null | One per non-NULL row | Row structs that flow directly into JSON APIs, code where readability matters more than micro-perf |
A practical default for a new HTTP service: use pointers in the structs you marshal to JSON, and use sql.Null[T] (or sql.NullString for older toolchains) in any internal type where you don't want to deal with nil checks on every read.
Cost: "One allocation per non-NULL row" sounds expensive but rarely is. A query that returns 1000 rows with 5 nullable columns allocates 5000 small heap blocks in the worst case, which Go's GC handles without breaking a sweat. The choice between wrapper and pointer should be driven by API design and code clarity, not by allocation counts, unless you've measured a real bottleneck.
For the writing path (INSERT, UPDATE), pointers are also the most natural: nil to insert NULL, &value to insert a value. The wrapper types work too, but the Valid: true boilerplate adds up.
10 quizzes