skip to content

Struct Tags and Mapping

The json tag is the whole mapping layer: rename a field, drop it with a dash, force a number into a string. Unexported fields never appear, which is the classic first surprise.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why does encoding/json skip a struct's unexported fields, even when they carry a json tag?

level: juniorimportance: must knowfreq 74%

answer

  1. capitalisation is doing more than style
  2. the encoder cannot reach every field
  3. no error, just missing data
  4. a tag on a lowercase field is inert

basics

~20 s

encoding/json can only see exported fields, meaning those whose names begin with a capital letter. Unexported fields are skipped when marshaling and left untouched when unmarshaling, so a json tag written on one has no effect at all.

solid answer

~50 s

The encoder and decoder reach a struct's fields reflectively, and a field whose name starts with a lowercase letter cannot be read or written from outside its package, so `encoding/json` ignores it entirely. Marshaling a struct whose fields are all unexported yields `{}` with no error. Decoding is the quieter failure: a key in the input that matches an unexported field's tag is simply dropped, the field keeps its zero value, and `json.Unmarshal` still returns nil, so it looks like the server never sent the data. A tag on an unexported field is inert, and `go vet`'s structtag check flags exactly that combination because it is nearly always a mistake. The fix is to export the field and use the tag to keep whatever wire name you want: `userID string` becomes `UserID string` tagged `json:"userID"`, and the JSON is unchanged.

code

go · 8 lines
go
type user struct {
	ID   int    `json:"id"`
	name string `json:"name"`
}

u := user{ID: 7, name: "ada"}
b, _ := json.Marshal(u)
// b is {"id":7}

go deeper

for a junior

Be ready to state the rule in one sentence and spot it in a struct: capital first letter or the field does not travel. Know that both directions are silent, with no error either way.

for a middle

Explain why the restriction exists: encoding happens through reflection, which honours the same export rules ordinary code does. Show the fix of exporting the field while keeping the wire name in the tag.

for a senior

Demonstrate how you would catch this before production: go vet's structtag check, plus a round-trip marshal-unmarshal test that a silently dropped field can never pass. Know the embedded-unexported-struct exception.

for a principal

Frame it as a review-time rule rather than a debugging skill. Decide whether vet runs in CI, whether generated structs are checked for it, and whether domain structs are allowed on the wire at all.

## The rule `encoding/json` marshals and unmarshals **only exported struct fields**. In Go, "exported" is not a keyword — it is capitalisation. An identifier whose first letter is an uppercase Unicode letter is visible outside the package that declares it; anything else (`name`, `userID`, `_id`) is package-private. `json.Marshal` and `json.Unmarshal` live in the standard library, which is a different package from your struct, so from their point of view an unexported field does not exist. ```go type user struct { ID int `json:"id"` name string `json:"name"` } // json.Marshal(user{ID: 7, name: "ada"}) -> {"id":7} ``` Note that the *type* being unexported (`user`, lowercase) is irrelevant — `json.Marshal` happily encodes a value of an unexported type. Only the **field** names matter. ## What each direction actually does **Marshaling.** Unexported fields are skipped. There is no error, no warning, and no placeholder in the output. A struct with no exported fields marshals to `{}`. **Unmarshaling.** This is where teams lose an afternoon. Given input `{"name":"ada"}` and a struct whose only field is `name string` tagged `json:"name"`, `json.Unmarshal` returns `nil` and the field is still `""`. From the caller's side that is indistinguishable from the server omitting the key, so people go and read HTTP logs before they read the struct. ## Why the restriction exists Assignment through reflection respects Go's export rules: the standard library cannot set a field it would not be allowed to set in ordinary code, otherwise every package boundary in the language would be one library call from being ignored. Reading is restricted for the same reason — an unexported field is an implementation detail its package may change freely, and silently serialising it would publish that detail on the wire. ## The tag is inert, and vet knows it A struct tag is just a string literal attached to a field. Writing `json:"name"` on an unexported field is legal Go, compiles cleanly, and does nothing. That is precisely why `go vet` has a check for it: its structtag analyzer verifies that tags parse in the conventional format **and** reports json or xml tags on unexported fields. Running `go vet ./...` in CI catches the mistake at the moment it is written. ## The fixes 1. **Export the field and control the name with the tag.** This is almost always the right answer. The Go identifier and the JSON name are independent, so exporting a field does not change your wire format: ```go type User struct { UserID string `json:"userID"` } ``` 2. **If the value genuinely must stay package-private,** keep the domain struct unexported-field-clean and encode a separate exported struct built for the wire, copying between them. What does *not* work: adding a tag, adding a getter method (`encoding/json` looks at fields, not methods, when deciding what to encode), or changing the tag's name to lowercase. Case of the **Go identifier** is what decides visibility; case of the **tag name** only decides the JSON key. ## One edge worth knowing There is a single exception to "unexported means invisible": an **embedded field whose type is an unexported struct type**. Because embedding promotes the inner type's fields into the outer struct, the inner type's *exported* fields are still promoted and encoded: ```go type base struct { ID string `json:"id"` } type Item struct { base Name string `json:"name"` } // json.Marshal(Item{base{"a"}, "b"}) -> {"id":"a","name":"b"} ``` `base` is unexported, but `base.ID` is exported, and promotion carries it into `Item`'s JSON object. An embedded field of unexported *non-struct* type is skipped as usual. ## How to notice this quickly The symptom is asymmetric and recognisable. On encode, a field you expected is simply missing from the body. On decode, a field is at its zero value while the raw JSON visibly contains it. Before checking the network, print the struct definition and look at the first letter of the field name; then run `go vet ./...`, which will point at the tag directly. A round-trip unit test — marshal a fully populated struct, unmarshal it back, compare — catches every instance of this class before it ships, because an unexported field can never survive the round trip.

  • The incoming JSON contains a key that matches an unexported field's tag. What does json.Unmarshal do?
    Nothing observable. The key is dropped, the field keeps its zero value, and `json.Unmarshal` returns nil. There is no error and no way to detect it from the return value, which is why a decoded struct with a suspiciously empty field should send you to look at the field's capitalisation rather than at the payload.
  • You need the JSON key to stay lowercase, but the field must be exported. How do you do that?
    Export the Go field and put the wire name in the tag: `UserID string` with `json:"userID"`. The Go identifier and the JSON key are completely independent — the tag name decides what appears on the wire, the identifier's case decides only whether the field is visible to `encoding/json` at all.
  • Does adding a getter method make an unexported field appear in the JSON?
    No. `encoding/json` walks a struct's fields, not its methods, so a `Name()` getter is never consulted. The only method-based hook is implementing a marshaler on the type itself, which is a different mechanism entirely; a plain getter has no effect on encoding at all.

The first letter of the field name is the doorway; the tag is only the label on the door. A label on a bricked-up doorway still lets nobody through.

saying these in an interview costs you the question

  • Claims a json tag makes an unexported field visible
  • Expects json.Marshal to return an error for unexported fields
  • Thinks json.Unmarshal reports the dropped key as an error
  • Believes the tag name, not the identifier's case, controls export
  • Adds a getter and expects encoding/json to call it
open as a page

What does the struct tag json:"user_id,omitempty" change about how encoding/json handles that field?

level: juniorimportance: must knowfreq 82%

basics

~20 s

The tag renames the field to user_id in JSON, on both encoding and decoding. The omitempty option additionally drops the field from the encoder's output when its value is false, 0, nil, or an empty string, slice, array or map.

open as a page

Why can a json struct tag be silently ignored, leaving encoding/json using the Go field name?

level: middleimportance: should knowfreq 46%

basics

~20 s

A struct tag is an ordinary string literal the Go compiler never validates. If it breaks the conventional key:"value" form, the json lookup finds nothing and encoding/json falls back to encoding the field under its Go name.

open as a page

A generated struct embeds two types that each tag a field json:"id". What does encoding/json emit?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Neither field appears. Embedded structs are flattened into the outer JSON object, but when two promoted fields sit at the same shallowest depth and claim the same name, encoding/json drops both of them and reports no error at all.

open as a page

In a json struct tag, what does the ,string option do to the field's encoded value?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

The ,string option makes encoding/json write the field's value inside a JSON string, so an int64 holding 63 encodes as "63" rather than 63, and requires a quoted value when decoding. It applies only to string, integer, floating-point and boolean fields.

open as a page