skip to content

Why does a struct with a sql.NullString field marshal to a JSON object instead of a string or null?

level: middleimportance: nice to knowfreq 30%

answer

  1. it is just a struct with two fields
  2. Scanner and Valuer, not Marshaler
  3. the encoder has no special case for it
  4. Valid false ships as an object
  5. the wire type wants a pointer

basics

~20 s

sql.NullString is an ordinary two-field struct and implements neither json.Marshaler nor encoding.TextMarshaler, so encoding/json applies its default struct rules and writes {"String":"","Valid":false}. It also fails to decode a bare JSON string. Use a *string in the wire type instead.

solid answer

~40 s

`sql.NullString` is `struct { String string; Valid bool }` and nothing more. It implements `sql.Scanner` and `driver.Valuer` so `database/sql` can read a SQL NULL into it, but it implements neither `json.Marshaler` nor `encoding.TextMarshaler`, so `encoding/json` has no special knowledge of it and falls back to encoding it as a struct with two exported fields. A NULL becomes `{"String":"","Valid":false}` instead of `null`, and decoding `"ada"` into that field fails with a `*json.UnmarshalTypeError` because a JSON string is not a JSON object. `omitempty` does not rescue it either, since a struct is never empty. The fix is to stop sending the storage type over the wire: keep a separate response struct whose field is `*string`, where nil encodes as `null`, or define your own type with `MarshalJSON`/`UnmarshalJSON`.

code

go · 10 lines
go
type Row struct {
	Name sql.NullString `json:"name"`
}

b, _ := json.Marshal(Row{})
// b is {"name":{"String":"","Valid":false}}

var r Row
err := json.Unmarshal([]byte(`{"name":"ada"}`), &r)
// err is a *json.UnmarshalTypeError: a JSON string is not a JSON object

go deeper

for a junior

Know that this type is a plain struct with a String and a Valid field, and that JSON encoders write it as an object with those two keys rather than as a bare string or null.

for a middle

Explain the dispatch: encoding/json looks for json.Marshaler and encoding.TextMarshaler, this type implements neither, so the default struct rule applies. Then give the pointer-based wire type as the fix.

for a senior

Frame it as a layering defect rather than a quirk. One struct serving both the database row and the HTTP response leaks storage concerns into a published contract, and this is only the most visible symptom of that.

for a principal

Set the rule for the codebase: storage types and wire types are separate, with an explicit conversion. That costs a mapping function per resource and buys the freedom to change either side without renegotiating the other.

## What sql.NullString actually is ``` type NullString struct { String string Valid bool // Valid is true if String is not NULL } ``` That is the whole type. Its purpose is to give `database/sql` somewhere to put a column that may be SQL NULL: it implements the `sql.Scanner` interface for reading and the `driver.Valuer` interface for writing. Those are database interfaces. They have nothing to do with JSON, and `encoding/json` does not look for them. ## Why the JSON looks the way it does `encoding/json` special-cases only a small set of things: types implementing `json.Marshaler`, types implementing `encoding.TextMarshaler`, and the built-in kinds. `sql.NullString` is none of those, so the encoder applies the ordinary rule for a struct — walk the exported fields, use the field names since there are no `json` tags — and produces: - for a present value: `{"String":"ada","Valid":true}` - for a SQL NULL: `{"String":"","Valid":false}` Neither is what a client expects. The second is worse than merely verbose: it is a document that claims a string field exists and holds the empty string, when the truth is that there is no value. Decoding is worse still. Handed `{"name":"ada"}` for a `sql.NullString` field, the decoder sees a JSON string where the Go type is a struct and returns a `*json.UnmarshalTypeError`. Handed `{"name":null}` it does nothing at all, since `null` into a struct is the documented no-op — so a null leaves `Valid` at whatever it already was, which for a fresh struct is `false`, and the round trip appears to work by accident. The same reasoning applies verbatim to `sql.NullInt64`, `sql.NullBool`, `sql.NullTime` and the rest of the family. ## The tag does not save you The first instinct is to add `omitempty`. It does nothing here: a struct is never empty by the encoder's definition, so the field is written whatever it holds. The `omitzero` option (Go 1.24) does help for the NULL case specifically, since `sql.NullString{}` is the zero value of its type and would be omitted — but omission is a third meaning, not `null`, and a present-but-false `Valid` with a non-empty `String` still marshals as the two-field object. Tags are the wrong layer for this problem. ## The real fix: separate the storage type from the wire type The underlying mistake is letting one struct be both the row scanned out of the database and the document sent to a client. Those are two contracts with different owners and different rates of change. Once they are separate types, the wire type can use exactly the shape JSON wants: - `*string` — nil encodes as `null`, non-nil encodes as the string, and `null` decodes back to nil. This is the idiomatic answer and needs no code. - your own named type with `MarshalJSON` and `UnmarshalJSON` — worth it when the null representation is unusual, or when you want the same type used consistently across many structs. The conversion between the two types is a handful of lines and it is the natural place to put every other presentation decision as well: which fields are exposed at all, what a timestamp looks like, what an ID is called. ## Why this appears in interviews It is a compact test of whether someone understands that `encoding/json` has no registry of known types — it dispatches on interfaces and kinds, and everything else gets the default struct treatment. A candidate who says "the standard library should special-case it" has the model backwards; a candidate who says "it implements the database interfaces, not the JSON ones" has it exactly right. ## What to say in an interview Name the two fields, name the two interfaces it does implement and the two it does not, give the `{"String":"","Valid":false}` output, mention that decoding a bare string is a type error, and land on the separate wire struct with `*string`. That is the whole answer in five sentences.

  • Would adding omitempty to a sql.NullString field fix the output?
    No. `omitempty` omits only values on the encoder's empty list, and a struct is never on it, so the two-field object is written regardless. `omitzero` (Go 1.24) would omit the zero `sql.NullString`, but omitting a key is a different message from sending `null`, and a set-but-invalid value still marshals as the object.
  • What happens when you decode {"name":null} into a sql.NullString field?
    Nothing happens, and no error is returned: a JSON `null` into a struct is the documented no-op in `encoding/json`. On a freshly zeroed struct that leaves `Valid` false, which looks correct, but on a struct being reused or overlaid it silently keeps the previous value. Relying on that accident is how a stale name reaches a client.
  • When is defining your own type with MarshalJSON better than just using *string?
    When the same optional shape recurs across many structs and you want one place to state it, or when the JSON form is not simply value-or-null — a wrapped object, a sentinel, a formatted timestamp. For a single optional string, `*string` is less code, needs no method to keep in sync, and is what a reader already understands.

It is a database adapter that got put in the shop window: it was shaped to fit a driver's socket, not to be looked at by a customer.

saying these in an interview costs you the question

  • Expects encoding/json to special-case sql.NullString
  • Thinks sql.NullString marshals to null when Valid is false
  • Believes omitempty will drop an invalid sql.NullString
  • Assumes decoding a bare JSON string into it works
  • Sees no problem sharing one struct between the database and the wire