Why does a struct with a sql.NullString field marshal to a JSON object instead of a string or null?
answer
- it is just a struct with two fields
- Scanner and Valuer, not Marshaler
- the encoder has no special case for it
- Valid false ships as an object
- the wire type wants a pointer
basics
~20 ssql.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 linestype 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 objectgo deeper
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.
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.
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.
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