Sunday, October 25, 2026
Why Do I Need A Pointer Just To Say A Value Is Missing?
Posted by

Introduction
You need a pointer to say a value is missing because a plain value has no missing state: every int is some number, and 0 is both a real age and the value Go gives an untouched int. A pointer adds one more state, nil, which means no value was supplied, while a pointer to 0 means the value 0 was supplied.
Before You Start
- Value: data held directly in a variable, such as
42in anintor"Asha"in astring. A value variable always holds something, even before assignment. - Zero value: the value Go gives a variable that has not been assigned. It is
0for numbers,""for strings,falsefor booleans, andnilfor pointers, slices, maps, channels, interfaces, and functions. Struct fields are zeroed recursively, so a struct value has every field at its zero value. - Pointer: a variable that holds the address of a value, written
*Tfor a pointer to a value of typeT. The&operator takes the address of a value, and*in front of a pointer reads the value at that address. - Nil: the zero value of a pointer. A nil pointer points at nothing. It is a valid value to hold and to compare, and it is not a valid value to dereference.
- Dereference: reading or writing through a pointer with
*, or reading a field through a struct pointer. Dereferencing a nil pointer panics withinvalid memory address or nil pointer dereference. - Present versus missing: present means a caller supplied a value, even if that value equals the zero value. Missing means no value was supplied at all. The whole article turns on keeping those two states apart.
- Sentinel value: a value chosen by convention to mean missing, such as
-1for an age or""for a name. A sentinel is still a value of the type, so it can collide with a real value. - Comma-ok: the two value form of a map lookup, a type assertion, or a channel receive, written
value, ok := m[key]. The second value reports whether the lookup found something, which is what separates a present zero value from a missing key. - Valid flag: the pattern used by
database/sqlnullable types, where a struct holds the value and aboolfield namedValid.Validis true when the database value was notNULL, and false when it was. - omitempty: a struct tag option in
encoding/jsonthat omits a field from encoding when the field holds an empty value. A nil pointer counts as empty, while a pointer to a zero value does not.
Prerequisites
- Go 1.20 or later, because the article uses generics for one helper, and generics are stable in that version.
- A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.
Project Layout
pointer-missing/
├── go.mod # module declaration, Go version
├── main.go # profile type, pointer helpers, and main function
└── main_test.go # missing, present zero, null, and helper tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the profile type and the pointer helpers, so the missing versus present rules can be read in order.
- main_test.go: holds tests that prove a missing field, a present zero value, and an explicit JSON null behave differently, or do not.
The Core Problem: Zero Values Cannot Say Missing
Go gives every variable a value, even before assignment. That design removes uninitialized variables, and it also removes the idea of an empty int. An int field in a struct is always a number. If a caller leaves it untouched, it reads as 0. If a caller sets it to 0 on purpose, it also reads as 0. No inspection of the field alone can tell those two cases apart, because after the fact they are the same value.
| Type | Zero value | A real value that looks identical |
|---|---|---|
int | 0 | A count of zero, an age of zero, a limit of zero |
string | "" | An intentionally empty name, note, or code |
bool | false | An explicit no, an explicit opt out, a disabled flag |
float64 | 0 | A price of zero, a score of zero, a rate of zero |
A string field has the same problem in the other direction. An empty string can mean the caller left the field blank, or the caller typed nothing on purpose, or the caller cleared a previous value. A boolean field is worse, because false is the default and also a meaningful answer, so a missing answer and a no answer are the same value.
This is not a flaw in the zero value design. Zero values make a fresh struct usable without a constructor, and they make a missing configuration field behave like a sensible default in many programs. The problem appears only when the program must distinguish missing from present, because then the default and the answer share one representation, and one representation cannot carry two meanings.
The wider question of why Go has no built-in option type is covered in Why Does Go Have No Real Enums Or Option Type?; this article stays on the narrower pointer question.
Why A Pointer Adds A Third State
A pointer field has three states that a plain field does not have, or rather, it has two states where a plain field has one ambiguous state. A nil pointer means missing. A non-nil pointer means present, and the value it points to is the supplied value, even when that value is the zero value. The pointer itself carries the presence information, and the pointed-to value carries the data.
type Profile struct {
Name string
Age *int
}
var missing Profile
var zeroAge int
present := Profile{Age: &zeroAge}
In this snippet, missing.Age is nil, so no age was supplied. present.Age is non-nil and points to 0, so an age of 0 was supplied. The check is a direct comparison, not a guess about the value:
if profile.Age == nil {
fmt.Println("age not provided")
} else {
fmt.Println("age is", *profile.Age)
}
The cost of this extra state is that every read must handle nil first. A pointer field is a contract with every reader: check for nil before dereferencing, or accept a panic. That contract is the price of the missing state, and it is worth paying only for fields where missing and present must be told apart. For a required field, a plain value is the better choice, because a required field has no missing state to represent.
A pointer to a struct field works the same way, and the field access syntax hides the dereference. profile.Bio may be a *string, and *profile.Bio reads the string, while profile.Bio == nil tests presence. For a pointer to a struct, Go lets a reader write p.Name instead of (*p).Name, but that shorthand does not remove the nil check: a nil struct pointer still panics on field access.
Dereference Safety
The only safe order for reading a pointer field is check, then dereference. A nil check is cheap to write and often forgotten, and the failure mode is a panic, not a wrong value, so a forgotten check is loud in tests and in production.
func AgeLabel(profile Profile) string {
if profile.Age == nil {
return "age not provided"
}
return fmt.Sprintf("age %d", *profile.Age)
}
A small generic helper keeps that check in one place, and gives callers a fallback value for the missing case:
func DerefOr[T any](p *T, fallback T) T {
if p == nil {
return fallback
}
return *p
}
With that helper, a caller writes DerefOr(profile.Age, -1) only when -1 is a display fallback, not a stored sentinel. The fallback lives at the read site, where its meaning is visible, instead of in the stored data, where it could be mistaken for a real value.
One trap in this area is new. new(int) allocates an int, sets it to 0, and returns a non-nil pointer to it. That pointer means present with value 0, not missing. A reader that uses new to create an optional field has created a present zero value by accident, and the missing state is lost. The way to create a missing field is to leave the pointer nil, and the way to create a present field is to take the address of a value, with &value or a helper that returns &value.
A second trap is sharing. Two structs that hold the same *int share one value, so a write through one pointer is visible through the other. That sharing is sometimes the point of a pointer, and sometimes a bug. A pointer field that is meant to be independent data should get its own allocation, not a shared pointer copied from another struct.
JSON: Present, Missing, And Null
JSON is where the pointer question becomes concrete, because JSON has three input states and Go has to map them onto a struct. The encoding/json rules for pointers are documented, and they drive the design of the complete example. This section covers encoding/json v1, the version in Go 1.20. In encoding/json/v2, omitempty is redefined by the encoded JSON value, so the plain value omitempty case below is v1 behaviour.
- Marshal, present: a non-nil pointer encodes as the value it points to. A pointer to
0encodes as0, and a pointer to""encodes as"". - Marshal, missing: a nil pointer encodes as the JSON
nullvalue, unless the field hasomitempty, in which case the field is omitted entirely. Theomitemptydefinition lists a nil pointer as an empty value, so a nil pointer field withomitemptydoes not appear in the output at all. - Unmarshal, present: JSON
0into a*intfield allocates anint, sets it to0, and stores a non-nil pointer. JSON""into a*stringfield does the same for a string. - Unmarshal, missing: an absent key leaves the field untouched. For a fresh struct, that means the pointer stays nil, so absent and missing line up.
- Unmarshal, explicit null: JSON
nullinto a pointer sets the pointer to nil. That is the documented rule, and it has a consequence worth stating plainly: with a pointer field alone, an absent key and an explicitnullare indistinguishable after decoding, because both leave the field nil. If a protocol must tell absent apart from explicit null,encoding/jsonwith a pointer field cannot do it, and a custom unmarshal method or a wrapper with its own presence flag is needed.
The omitempty trap follows from the same rules. A plain int field with omitempty is omitted when it holds 0, so a present zero value disappears from the output, and a reader of that JSON cannot tell whether the field was zero or missing. A *int field with omitempty is omitted only when nil, so a present zero value survives as 0 in the output, and a missing value disappears. That is the reason pointer fields and omitempty are paired so often: the pointer restores the distinction that omitempty on a plain value destroys.
Without omitempty, the distinction is still there, in a different form. A nil pointer field encodes as null, so the key is present with a null value, while a present zero value encodes as 0. A reader that treats null as missing can work with that form, and a reader that needs the key to be absent must use omitempty.
Database Nulls: The Valid Flag Alternative
A database column has a NULL state that Go values do not have, so database/sql solves the same problem with a different shape. A NullString holds two fields: String, the value, and Valid, a boolean that is true when the column value was not NULL. The same shape exists for NullInt64, NullFloat64, NullBool, and NullTime, each with its value field and a Valid field.
var name sql.NullString
err := row.Scan(&name)
if err != nil {
return err
}
if name.Valid {
fmt.Println("name is", name.String)
} else {
fmt.Println("name is NULL")
}
The Valid flag pattern has one advantage over a pointer: it cannot panic on a forgotten nil check, because there is no pointer to dereference. It has two costs of its own. First, the two fields must be read together, and reading String without checking Valid repeats the original mistake in a quieter form, because String holds "" for a NULL value. Second, a NullString does not marshal to JSON as a bare string or as null; it marshals as an object with String and Valid fields, so it must be converted to a pointer or a plain value before it reaches a JSON response. The Valid flag is the right shape at the database boundary, and a pointer is usually the right shape at the JSON boundary, and converting between them at the boundary is normal work, not a design failure.
Other Alternatives And Their Costs
A pointer is not the only way to carry presence, and each alternative trades a different cost for the same information.
- Comma-ok, for maps: a map lookup with two values,
value, ok := m[key], reports presence directly. A missing key gives the zero value withokfalse, and a present key holding the zero value gives the zero value withoktrue. This works because the map, not the value, carries the presence information. It does not transfer to struct fields, because a struct field has no lookup to return a second value. - Separate bool field: a struct can hold
Age intandHasAge bool, orAge intandAgeSet bool. This keeps the value and the presence flag together, and it cannot panic, like the Valid flag pattern. Its cost is that the two fields must stay in sync on every write, every copy, and every decode, and a reader must remember to check the flag, which is the same discipline as a nil check, with more fields to keep consistent. - Sentinel value: a value such as
-1for an age, or a special string such as"unknown", can mean missing by convention. Its cost is collision:-1stops meaning missing the day a real value of-1becomes valid, and a sentinel string stops meaning missing the day a user types it. A sentinel also leaks the convention to every reader, every database, and every API consumer, and a reader that does not know the convention reads the sentinel as data. - Error return, for lookups: a function can return a value and an error, or a value and a boolean, to report absence. This works for a lookup function, and it is the idiomatic shape for a function that may not find something. It does not work for a struct field, because a field has no return value to carry the second result.
- Wrapper type with a presence flag: a generic wrapper holding a value and a boolean is the same idea as the separate bool field, packaged as one type. It is a third party pattern in Go, not a standard library type, and it costs a new type, custom JSON handling, and a learning curve for every reader, in exchange for a presence flag that cannot drift from its value. That trade is worth it when many fields need the same treatment, and it is overkill for one optional field.
The pointer wins for a single optional struct field because it needs no extra field, no convention, and no new type, and because encoding/json already understands it. It loses when the field is required, when the value must never be nil, or when the presence flag must survive a format, such as JSON, that distinguishes absent from explicit null, because a pointer collapses those two states.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Plain value | The field is required, or missing and zero mean the same thing | Cannot distinguish missing from a present zero value |
| Pointer field | One optional struct field, especially one that crosses a JSON boundary | Every read needs a nil check, and a forgotten check panics |
| Pointer with omitempty | A missing field should be absent from JSON, and a present zero value should be kept | Absent and explicit null are indistinguishable after decoding |
| Pointer without omitempty | A missing field should appear as JSON null | The key is always present, so readers must treat null as missing |
| Comma-ok map lookup | Presence comes from a map key, not from a struct field | Does not apply to struct fields, which have no second return value |
| Separate bool or Valid flag | A database boundary, or a value that must never panic on read | Two fields must stay in sync, and the flag must be checked before the value |
| Sentinel value | A legacy format already uses one, and its collision range is proven impossible | Collides the day a real value equals the sentinel, and the convention leaks to every reader |
| Error or bool return | A lookup function may not find something | Does not apply to a stored struct field |
| Wrapper type with presence flag | Many fields need the same presence treatment, with custom JSON rules | A new type, custom marshaling, and a learning curve for every reader |
A design in this article has a deliberate compromise, stated openly. The complete example uses one profile struct, not a database row, so the database/sql Valid flag pattern is shown as a snippet, not as running code against a database, because the example needs no database driver. The pointer rules themselves are not compromised: the profile struct uses pointer fields for optional data, plain fields for required data, and a nil check before every dereference.
Complete Example
The complete example is a profile with one required field and three optional fields. Name is a plain string, because a profile without a name is not a valid profile in this example. Age and Nickname are pointers with omitempty, so a missing value is absent from JSON and a present zero value is kept. Bio is a pointer without omitempty, so a missing value appears as JSON null, which shows the two pointer forms side by side.
// go.mod
module pointermissing
go 1.20
// main.go
package main
import (
"encoding/json"
"fmt"
)
type Profile struct {
Name string `json:"name"`
Age *int `json:"age,omitempty"`
Nickname *string `json:"nickname,omitempty"`
Bio *string `json:"bio"`
}
func IntPtr(value int) *int {
return &value
}
func StrPtr(value string) *string {
return &value
}
func DerefOr[T any](p *T, fallback T) T {
if p == nil {
return fallback
}
return *p
}
func AgeLabel(profile Profile) string {
if profile.Age == nil {
return "age not provided"
}
return fmt.Sprintf("age %d", *profile.Age)
}
func main() {
missing := Profile{Name: "Asha"}
missingJSON, err := json.Marshal(missing)
if err != nil {
panic(err)
}
fmt.Println(string(missingJSON))
presentZero := Profile{
Name: "Bhanu",
Age: IntPtr(0),
Nickname: StrPtr(""),
}
presentJSON, err := json.Marshal(presentZero)
if err != nil {
panic(err)
}
fmt.Println(string(presentJSON))
var fromEmpty Profile
if err := json.Unmarshal([]byte(`{}`), &fromEmpty); err != nil {
panic(err)
}
fmt.Println("empty object age present:", fromEmpty.Age != nil)
var fromZero Profile
if err := json.Unmarshal([]byte(`{"age":0}`), &fromZero); err != nil {
panic(err)
}
fmt.Println("zero age present:", fromZero.Age != nil, "value:", DerefOr(fromZero.Age, -1))
var fromNull Profile
if err := json.Unmarshal([]byte(`{"age":null}`), &fromNull); err != nil {
panic(err)
}
fmt.Println("null age present:", fromNull.Age != nil)
fmt.Println(AgeLabel(fromZero))
fmt.Println(AgeLabel(missing))
}
Commands to run it, from the pointer-missing folder:
go run .
Expected output:
{"name":"Asha","bio":null}
{"name":"Bhanu","age":0,"nickname":"","bio":null}
empty object age present: false
zero age present: true value: 0
null age present: false
age 0
age not provided
Why this output is the whole argument, in order:
- The missing profile has no
ageand nonicknamekey, because both pointers are nil and both fields haveomitempty, whilebioappears asnull, because it has noomitempty. - The present zero profile keeps
ageas0andnicknameas"", because both pointers are non-nil, even though both values equal their zero values. - An empty JSON object leaves
Agenil, so absent means missing. - A JSON
0gives a non-nil pointer to0, so present zero is visible. - A JSON
nullgives a nil pointer, so explicit null and absent collapse to the same state, which is the documented limit of the pointer approach. AgeLabelprintsage 0for the present zero case andage not providedfor the missing case, because it checks nil before dereferencing.
Testing Pointers
Test a pointer field by presence, not by value alone. A test that checks only the value cannot tell a missing field from a present zero value, because both read as the zero value after a careless dereference.
// main_test.go
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestMissingVsPresentZero(t *testing.T) {
var fromEmpty Profile
if err := json.Unmarshal([]byte(`{}`), &fromEmpty); err != nil {
t.Fatalf("unmarshal empty object: %v", err)
}
if fromEmpty.Age != nil {
t.Errorf("empty object age = non-nil, want nil")
}
var fromZero Profile
if err := json.Unmarshal([]byte(`{"age":0}`), &fromZero); err != nil {
t.Fatalf("unmarshal zero age: %v", err)
}
if fromZero.Age == nil {
t.Fatal("zero age = nil, want non-nil pointer")
}
if *fromZero.Age != 0 {
t.Errorf("zero age = %d, want 0", *fromZero.Age)
}
}
func TestMarshalPresentZeroKeptAndMissingOmitted(t *testing.T) {
present, err := json.Marshal(Profile{Name: "Bhanu", Age: IntPtr(0), Nickname: StrPtr("")})
if err != nil {
t.Fatalf("marshal present zero: %v", err)
}
if !strings.Contains(string(present), `"age":0`) {
t.Errorf("present zero JSON = %s, want it to contain age 0", present)
}
if !strings.Contains(string(present), `"nickname":""`) {
t.Errorf("present zero JSON = %s, want it to contain an empty nickname", present)
}
missing, err := json.Marshal(Profile{Name: "Asha"})
if err != nil {
t.Fatalf("marshal missing: %v", err)
}
if strings.Contains(string(missing), `"age"`) {
t.Errorf("missing JSON = %s, want no age key", missing)
}
}
func TestNullUnmarshalsToNil(t *testing.T) {
var fromNull Profile
if err := json.Unmarshal([]byte(`{"age":null}`), &fromNull); err != nil {
t.Fatalf("unmarshal null age: %v", err)
}
if fromNull.Age != nil {
t.Errorf("null age = non-nil, want nil")
}
}
func TestNicknameEmptyStringIsPresent(t *testing.T) {
var profile Profile
if err := json.Unmarshal([]byte(`{"nickname":""}`), &profile); err != nil {
t.Fatalf("unmarshal empty nickname: %v", err)
}
if profile.Nickname == nil {
t.Fatal("empty nickname = nil, want non-nil pointer")
}
if *profile.Nickname != "" {
t.Errorf("empty nickname = %q, want empty string", *profile.Nickname)
}
}
func TestDerefOr(t *testing.T) {
if got := DerefOr[int](nil, 42); got != 42 {
t.Errorf("DerefOr(nil, 42) = %d, want 42", got)
}
if got := DerefOr(IntPtr(0), 42); got != 0 {
t.Errorf("DerefOr(pointer to 0, 42) = %d, want 0", got)
}
}
func TestAgeLabel(t *testing.T) {
if got := AgeLabel(Profile{Name: "Asha"}); got != "age not provided" {
t.Errorf("AgeLabel missing = %q, want age not provided", got)
}
if got := AgeLabel(Profile{Name: "Bhanu", Age: IntPtr(0)}); got != "age 0" {
t.Errorf("AgeLabel present zero = %q, want age 0", got)
}
}
The missing versus present zero test is the core test for this article: it decodes both inputs and checks the pointer, not the value. The marshal test checks the output side of the same rule, that a present zero survives and a missing field is omitted. The null test records the documented limit, that explicit null and absent both decode to nil. The helper test checks that a fallback does not overwrite a present zero value, which is the mistake a careless fallback makes.
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test, including the present zero cases, because the pointer preserves presence through both directions of JSON handling. go vet reports suspicious constructs. It does not prove that a pointer is checked before every dereference, so do not treat a clean vet run as proof that no nil panic exists. The tests above are the proof for this example, and they cover the missing, present zero, and null cases.
Common Mistakes
- Dereferencing without a nil check: a reader that writes
*profile.Agewithout checking nil first panics on every missing field. The fix is a nil check before every dereference, or a helper such asDerefOrthat holds the check in one place. - Using
newto mean missing:new(int)returns a non-nil pointer to0, so it means present with value0, not missing. The fix is to leave an optional pointer nil for missing, and to take the address of a value for present. - Using
omitemptyon a plain value to mean missing: a plainintfield withomitemptydrops a present0from JSON, so the reader cannot tell zero from missing. The fix is a pointer field withomitempty, so only nil is omitted. - Reading a Valid flag value without checking Valid: a
sql.NullStringwithValidfalse holds""inString, so readingStringalone repeats the zero value mistake. The fix is to checkValidbefore using the value, every time. - Treating a sentinel as data: a sentinel such as
-1for a missing age is read as a real age by any reader that does not know the convention, and it collides with a real value the day-1becomes valid. The fix is a pointer or a Valid flag, where missing has its own representation. - Using a single value map lookup to test presence:
value := m[key]gives the zero value for a missing key and for a present key holding the zero value, so the two cases are indistinguishable. The fix is the comma-ok form,value, ok := m[key], and a check ofok. - Assuming JSON null and JSON absent are different after decoding: with a pointer field, both decode to nil, so a program that needs to tell them apart has already lost the information. The fix is a custom unmarshal method or a wrapper with its own presence flag, decided before the type is published, not after data is lost.
- Copying a shared pointer and mutating through it: two structs that hold the same
*intshare one value, so a write through one struct changes the other. The fix is a fresh allocation per struct when the data is meant to be independent, and a documented shared pointer when sharing is intended. - Making every field a pointer: a required field as a pointer adds a nil check to every reader for no benefit, because the field has no missing state to represent. The fix is a plain value for required fields, and a pointer only for fields where missing and present must be told apart.
- Using a pointer to a slice, map, or channel to mean missing: slices, maps, and channels are already reference types with a nil zero value, so a nil slice already means missing without a pointer. The fix is a plain slice, map, or channel field, and a nil check on the field itself.
- Using a pointer for a large struct only to avoid copying, without a nil contract: a pointer to a large struct avoids copying the struct on every pass, and it also adds a nil state that every reader must handle. The fix is to decide which benefit is wanted: if the goal is avoiding a copy, a pointer receiver or a pointer parameter with a documented non-nil contract is the tool, and if the goal is optionality, a pointer field with nil checks is the tool. Mixing the two goals without stating which one applies is how a nil panic enters a hot path.
Production Notes
- API contracts: decide, per field, whether missing means absent from JSON, null in JSON, or an error. A pointer with
omitemptygives absent, a pointer withoutomitemptygives null, and a plain required field gives an error only if validation rejects the zero value, which it cannot do reliably. Write the contract down, because a consumer cannot infer it from one example payload. - Validation: validate presence at the boundary, where the data enters the program, not deep inside business logic. A handler that checks nil once and converts a pointer field to a plain value with a fallback gives the rest of the program a value it can trust, instead of spreading nil checks through every function.
- Database boundaries: convert between
sql.Null*types and pointer fields at the repository boundary. A handler should not see aNullStringin a JSON response, and a database query should not receive a raw pointer without a nil toNULLconversion. The boundary conversion is where the two presence representations meet, and it is the place to get it right once. - Observability: a nil pointer panic is loud and has a clear stack trace, which is better than a silent wrong value from a sentinel or a dropped zero value. A panic in production still means a missing nil check, so treat a nil panic as a contract bug, not as bad input, and fix the check, not the caller.
- Performance, stated without numbers: a pointer copies an address instead of a whole struct, and it adds an indirection on every read, plus a heap allocation when the pointed-to value escapes. For a small value such as an
int, a pointer field is a presence tool, not a performance tool, and its cost is the nil check, not the copy it saves. For a large struct, the copy saving is real, and so is the nil risk, so the decision belongs in the tradeoffs table, not in a blanket rule. - Deployment: the example needs Go 1.20 or later. Pin that version in
go.mod, rungo vet ./...andgo test ./...in continuous integration, and do not deploy a change that fails the missing versus present zero tests. A vet pass alone is not enough for a pointer type, because vet does not prove that every dereference is guarded.
Decision Guide
- When a field is required: use a plain value, and validate it with rules that do not depend on the zero value alone, because a required field has no missing state to represent.
- When a field is optional and crosses a JSON boundary: use a pointer field, with
omitemptywhen missing should be absent, and withoutomitemptywhen missing should appear as null. - When a field is optional and stays inside Go code: use a pointer field, or a separate bool field when a nil panic must be impossible, and keep the flag check next to every read.
- When presence comes from a map: use the comma-ok form, and check
ok, instead of adding a pointer to the map value. - When a value comes from a database column that can be NULL: use a
sql.Null*type at the scan boundary, checkValidbefore the value, and convert to a pointer before JSON encoding. - When a legacy format already uses a sentinel: keep the sentinel only while its collision range is proven impossible, document it at the boundary, and convert it to a pointer or a Valid flag on the way in.
- When a function may not find something: return a value and a boolean, or a value and an error, instead of storing absence in a struct field.
- When many fields need the same presence rules: consider a wrapper type with its own presence flag and custom JSON handling, and accept its learning curve, instead of repeating pointer checks with different conventions per field.
Conclusion
A plain value cannot say missing, because it always holds something, and its zero value is also a real answer. A pointer adds the missing state the plain value lacks, in the form of nil, and the price is a nil check before every read.
Summary Of Key Points
- Zero values are answers:
0,"", andfalseare real values, so they cannot also mean missing without a second signal. - Nil is the second signal: a nil pointer means missing, and a non-nil pointer to a zero value means present with that zero value.
- Check before dereferencing: a nil check, or a helper that holds the check, is the contract that comes with every pointer field.
- JSON keeps the distinction, with one limit: a pointer with
omitemptyomits missing and keeps present zero, while absent and explicit null both decode to nil. - Databases use a different shape: a
sql.Null*type pairs a value with aValidflag, which cannot panic, and which must be converted before JSON encoding. - Alternatives have their own costs: comma-ok works for maps, a separate bool must stay in sync, a sentinel collides with real data, and a wrapper costs a new type.
- Use pointers only where missing matters: required fields stay plain values, and optional fields get pointers, so every nil check pays for a distinction the program actually needs.
Follow those rules, and a missing value stops being a guess: it is nil, it is checked, and it is visible in JSON, in tests, and in the code that reads it.
Related Posts
- Why Does Go Have No Real Enums Or Option Type?
- Why Does Go JSON Turn My Empty Slice Into Null?
- Why Does My Nil Interface Not Equal Nil?
- Nil Pointer Dereference In Go
Learn more:
Additional Resources
- Go Documentation: The Go Programming Language Specification - zero values, pointer types, address and dereference operators, and index expressions with the comma-ok form
- Go Documentation: Effective Go - pointers, methods, and the rules for when a pointer receiver is appropriate
- Go Packages: encoding/json package - pointer encoding as the pointed-to value, nil pointer encoding as null, omitempty empty values, and unmarshal rules for null into a pointer
- Go Packages: database/sql package - NullString, NullInt64, NullFloat64, NullBool, and NullTime, each with a value field and a Valid field
- Go Packages: builtin package - the new function, which returns a non-nil pointer to a zero value, not a missing value
References
- The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec. Accessed 2026-10-25.
- Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go. Accessed 2026-10-25.
- encoding/json package. Go team, Go Packages. https://pkg.go.dev/encoding/json. Accessed 2026-10-25.
- database/sql package. Go team, Go Packages. https://pkg.go.dev/database/sql. Accessed 2026-10-25.
- builtin package. Go team, Go Packages. https://pkg.go.dev/builtin. Accessed 2026-10-25.