DEV Community

Cover image for Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.
Andrey Kolkov
Andrey Kolkov

Posted on

Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.

Go 1.22 gave us sql.Null[T] — a generic nullable type for SQL. Problem solved?

No. Try marshaling it to JSON:

{"V":"Alice","Valid":true}
Enter fullscreen mode Exit fullscreen mode

That's not "Alice". That's not null. That's a broken API response.

And it will never be fixed. Issue #68375 — proposal to add MarshalJSON to sql.Null[T] — was closed as infeasible. Go's policy forbids adding marshal methods to types that already have a "reasonable" default marshaling. The struct marshaling {"V":...,"Valid":...} counts as "reasonable."

This is a permanent gap in Go's standard library.

The Three Bad Options (Before opt)

1. sql.Null[T] — SQL works, JSON broken

type User struct {
    Name sql.Null[string] `json:"name"`
}
// Marshals to: {"name":{"V":"Alice","Valid":true}}
// You wanted:  {"name":"Alice"}
Enter fullscreen mode Exit fullscreen mode

2. *string — JSON works, everything else is awkward

type User struct {
    Name *string `json:"name"`
}
// Pointer allocation overhead on every value
// Can't distinguish "field absent" from "field is null"
// user.Name == nil — is this "not set" or "set to null"?
Enter fullscreen mode Exit fullscreen mode

3. guregu/null — Works, but carries 10 years of legacy

guregu/null (2,070 stars) is the de facto standard. It works. But:

  • v1→v6 legacy — API evolved over 10 years, backward compatibility constraints
  • No three-state — can't distinguish "field absent" from "field null" (critical for PATCH APIs)
  • No Map/FlatMap — no functional composition
  • No OrNull constructors — every project writes the same boilerplate helpers
  • Generic Value[T] has MarshalText and Equal commented out — couldn't make them work

opt: Option<T> for Go

coregx/opt — designed from scratch for Go 1.24+. No legacy, no compromises.

import "github.com/coregx/opt"

type User struct {
    Name  opt.String `json:"name"`
    Email opt.String `json:"email,omitzero"`
    Age   opt.Int    `json:"age"`
}

user := User{
    Name: opt.StringFrom("Alice"),
    Age:  opt.IntOrNull(0),  // 0 means "not set" → null
}

data, _ := json.Marshal(user)
// {"name":"Alice","age":null}
// Email omitted (omitzero) — not null, just absent
Enter fullscreen mode Exit fullscreen mode

JSON works. SQL works. No pointer overhead. No boilerplate.

What Makes opt Different

1. Three-State Field[T] — The PATCH API Killer Feature

Every REST API with PATCH endpoints has this problem: how do you distinguish "the client didn't send this field" from "the client explicitly set it to null"?

type PatchUser struct {
    Name  opt.Field[string] `json:"name,omitzero"`
    Email opt.Field[string] `json:"email,omitzero"`
    Age   opt.Field[int]    `json:"age,omitzero"`
}

// Client sends: {"name":"John","email":null}
var patch PatchUser
json.Unmarshal(input, &patch)

patch.Name.IsValue()   // true  → set name to "John"
patch.Email.IsNull()   // true  → set email to NULL in DB
patch.Age.IsAbsent()   // true  → don't touch age
Enter fullscreen mode Exit fullscreen mode

Three states: absent (don't touch), null (set to NULL), value (set to value).

Rust does this with Option<Option<T>>. Kotlin with nullable + optional. No Go library does this properly. Including guregu/null.

2. OrNull Constructors — No More Boilerplate

Every project using nullable types with a database writes the same helpers:

// This is in EVERY Go project with nullable DB fields
func optStr(s string) opt.String {
    return opt.NewString(s, s != "")
}
func optInt(n int64) opt.Int {
    if n == 0 { return opt.Int{} }
    return opt.IntFrom(n)
}
Enter fullscreen mode Exit fullscreen mode

With opt, this is built in:

row := CompanyRow{
    City: opt.StringOrNull(company.City()),     // "" → null
    OGRN: opt.StringOrNull(reg.OGRN()),         // "" → null
    Count: opt.IntOrNull(company.EmployeeCount()), // 0 → null
}
Enter fullscreen mode Exit fullscreen mode

One function per type. Zero boilerplate. Available for all 9 types including BoolOrNull.

3. Functional API — Map, FlatMap, Equal

Inspired by Rust's Option<T>:

// Transform if valid, propagate null
name := opt.From("  Alice  ")
trimmed := opt.Map(name, strings.TrimSpace)    // opt.Value[string]{"Alice", true}
length := opt.Map(trimmed, func(s string) int { return len(s) })  // opt.Value[int]{5, true}

// Chain operations that may produce null
parsed := opt.FlatMap(input, func(s string) opt.Value[int] {
    n, err := strconv.Atoi(s)
    if err != nil { return opt.New(0, false) }
    return opt.From(n)
})

// Nil-safe comparison
opt.Equal(opt.From(42), opt.From(42))  // true
opt.Equal(opt.From(42), opt.New(0, false))  // false
Enter fullscreen mode Exit fullscreen mode

guregu/null has ValueOr. That's it. No Map, no FlatMap, no OrElse.

4. Generic Value[T] That Actually Works

guregu's generic Value[T] has MarshalText and Equal commented out in the source code — they couldn't make them work generically.

opt's Value[T] is fully functional:

// Works with ANY type
v := opt.From(MyCustomStruct{Name: "test"})
data, _ := json.Marshal(v)  // {"Name":"test"}

null := opt.New(MyCustomStruct{}, false)
data, _ = json.Marshal(null)  // null
Enter fullscreen mode Exit fullscreen mode

5. zero/ Subpackage — Alternative Semantics

Sometimes you want zero values to be null, and null to marshal as zero (not null):

import "github.com/coregx/opt/zero"

s := zero.StringFrom("")   // Invalid — empty = null
data, _ := json.Marshal(s) // "" (not "null")

i := zero.IntFrom(0)       // Invalid — 0 = null
data, _ = json.Marshal(i)  // 0 (not "null")
Enter fullscreen mode Exit fullscreen mode
Package From("") Marshal null
opt Valid (empty string) null
opt/zero Invalid (null) ""

Comparison

Feature opt guregu/null *T sql.Null[T]
Generic Value[T] Full Partial N/A No JSON
Three-state (PATCH) Field[T] No No No
OrNull constructors Yes No No No
Map / FlatMap Yes No No No
OrElse (lazy) Yes No No No
JSON marshal Yes Yes Yes Broken
SQL Scanner/Valuer Yes Yes Yes Yes
omitzero (Go 1.24+) Yes Yes No No
json/v2 compatible Yes Yes Yes No
Legacy code None v1→v6 N/A N/A

Performance

Zero-allocation unmarshal. Bool operations under 2 nanoseconds:

BenchmarkBoolMarshalJSON     0.85 ns/op    0 allocs
BenchmarkBoolUnmarshalJSON   2.1 ns/op     0 allocs
BenchmarkIntUnmarshalJSON    193 ns/op     1 alloc
BenchmarkStringUnmarshalJSON 137 ns/op     0 allocs
BenchmarkStructMarshalJSON   876 ns/op     9 allocs
Enter fullscreen mode Exit fullscreen mode

Why New Projects Should Start with opt

  1. sql.Null[T] will never get JSON support — this is official Go team position (#68375)
  2. *T can't do three-state — nil means both "absent" and "null"
  3. guregu/null works but carries v1→v6 legacy and has no PATCH support
  4. opt starts clean — Go 1.24+, generics-first, no backward compatibility burden
  5. Field[T] is unique — no other Go library properly solves the PATCH three-state problem
  6. Zero dependencies — only Go stdlib
  7. json/v2 ready — works today, optimizable when json/v2 goes stable

Getting Started

go get github.com/coregx/opt@v0.2.0
Enter fullscreen mode Exit fullscreen mode
import "github.com/coregx/opt"

// Always valid
name := opt.StringFrom("Alice")

// Zero means "not set"
age := opt.IntOrNull(0)  // null

// From pointer (nil → null)
email := opt.StringFromPtr(emailPtr)

// Three-state for PATCH
type PatchRequest struct {
    Bio opt.Field[string] `json:"bio,omitzero"`
}
Enter fullscreen mode Exit fullscreen mode

Full API and examples: github.com/coregx/opt

Help Us Get to v1.0.0

opt is in active development. We're heading toward a stable v1.0.0 and need your help:

  • Try it in your project — replace *string or guregu/null and tell us how it goes
  • Report issues — edge cases, driver compatibility, unexpected behavior
  • Suggest features — what nullable types should do that no library does yet
  • Send PRs — new types, better tests, documentation improvements
  • Share — if opt solved a problem for you, tell your team or write about it

Every bug report, feature idea, and PR brings us closer to a stable API.

GitHub | Issues | pkg.go.dev


opt is part of the coregx ecosystem — high-performance Go libraries for production applications.

Top comments (0)