AlgoMaster Logo

Null Handling (sql.Null*)

High Priority11 min readUpdated June 6, 2026

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.

The NULL Scan Problem

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.

sql.NullString and Friends

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.

TypeValue FieldSQL Type
sql.NullStringString stringTEXT, VARCHAR, CHAR
sql.NullInt64Int64 int64BIGINT, INTEGER
sql.NullInt32Int32 int32INT, INTEGER
sql.NullInt16Int16 int16SMALLINT
sql.NullByteByte byteTINYINT (unsigned)
sql.NullFloat64Float64 float64FLOAT, DOUBLE, REAL
sql.NullBoolBool boolBOOLEAN, BIT
sql.NullTimeTime time.TimeTIMESTAMP, 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.

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.

sql.Null[T] (Go 1.22+)

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]:

  1. It requires Go 1.22 or newer. On older toolchains the type doesn't exist and the code won't compile.
  2. Not every 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.

Pointer Alternative

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.

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.

Writing NULLs Back

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:

  • A nil value of an interface or pointer type.
  • A zero-valued wrapper like sql.NullString{} (where Valid is false).
  • A non-zero wrapper with 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.

JSON Encoding Gotchas

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.

Fixing the JSON Shape with Custom MarshalJSON

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.

Decision Guide

Which of the three approaches should you use? Here's a side-by-side comparison.

ApproachMin Go VersionScan ErgonomicsJSON OutputAllocationsBest For
sql.NullString, sql.NullInt64, etc.Any (since Go 1.0 era)Verbose; one named type per SQL typeObject with Valid/value fieldsNone per rowLibrary code that must support old Go versions, internal types not exposed over HTTP
sql.Null[T]1.22+Verbose; uniform across typesObject with Valid/V fieldsNone per rowNew code on modern Go, internal types where you want consistency without one type per column kind
*string, *int64, etc.AnyClean; one type per column kindIdiomatic value or nullOne per non-NULL rowRow 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.

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.

Quiz

NULL Handling Quiz

10 quizzes