Only exported fields survive encoding/json
The standard library decodes through reflection, and reflection refuses to touch an unexported field. Name a field serviceID and json.Unmarshal walks straight past it. No error, no warning, an empty value at runtime and an afternoon spent reading the wrong part of the stack.
Every name this page writes starts with a capital letter for that reason, with the wire key kept in the tag beside it. Go style rules also want initialisms fully capitalised, so service_id becomes ServiceID rather than ServiceId, and base_url becomes BaseURL. Linters flag the alternative spelling, and the tag means the rename costs nothing at decode time.
serviceID string // unexported, so the decoder never sees it
ServiceID string // matches "ServiceID" or "serviceid", not "service_id"
ServiceID string `json:"service_id"` // matches the real wire keyThe middle line is worth a second look. With no tag at all, Go falls back to a case insensitive match on the field name, so Public picks up a key called public without help. That fallback stops at punctuation. An underscore in the key means nothing lines up, which is why the plain struct shape in the rail suits flat camelCase APIs and little else.
Reading the tag string
The tag is a raw string literal in backticks holding space separated key/value pairs. Only the json: pair matters to the standard library, and other packages read their own keys out of the same string. Two syntax rules bite hard because neither one produces an error.
- No space after the comma.
json:"cache_ttl, omitempty"parses the option as" omitempty", which matches nothing, so the field is written out even when empty. - Backticks, not double quotes. A tag written with escaped quotes compiles and then fails the reflection lookup at runtime.
Two options beyond omitempty earn their place. json:"-" drops a field from encoding and decoding both, the usual home for a password hash or an internal flag. json:",string" encodes a numeric field as a quoted string, which is what an API written in a JavaScript runtime often expects for an ID it plans to hold past 2^53.
What omitempty counts as empty is narrow: false, 0, an empty string, a nil pointer, a nil interface, and any array, slice or map with length zero. A nested struct is never empty, so Owner Owner `json:"owner,omitempty"` still marshals as "owner":{}. Make the field *Owner when the key needs to disappear.
Pointer or value, and the zero value trap
Go has no null. An int field left untouched by the decoder reads 0, exactly what it reads when the server sent 0 on purpose. For replica_count the difference is a service scaled to zero against a service the payload said nothing about, and a value type cannot tell you which happened.
With the pointer toggle on, any key missing from at least one record comes back as a pointer, so nil means absent and *value means present. The sample above puts cache_ttl in one route and leaves it out of the other, which is why it lands as *int:
if r.CacheTTL != nil && *r.CacheTTL > 0 {w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", *r.CacheTTL))}The bill for that precision is a nil check at every read site. Skip one and the panic lands in production rather than in the decoder. When a field is optional but a sane default exists, take the value type and let the zero value stand in. Keep pointers for the fields where absent and zero mean different things.
Slices and maps never get a pointer here, deliberately. Both already have a usable nil, and len(nil) is 0 rather than a panic, so *[]string buys nothing and adds a dereference. Database work is the other exception worth knowing: sql.NullInt64 and friends carry the same absent/present distinction in a shape the database/sql package already understands.
Numbers land wherever the records point them
JSON has one number type. Go has fifteen. The generator reads every record before deciding, so cpu_limit arriving as 2 in one service and 0.5 in the other widens to float64 instead of guessing int from the first row and failing on the second with cannot unmarshal number 0.5 into Go struct field of type int.
Paste a single record and that protection disappears. One record showing 118 for p95_ms gives you an int field, and the first decimal latency from production takes the handler down.
The sharper problem shows up with large integers. Decode into interface{} or map[string]interface{} and every number becomes a float64, which holds whole numbers exactly only up to 9007199254740991. A 19 digit Twitter style ID or a Snowflake key comes back rounded, and the round trip writes a corrupted value back upstream. The fix is a decoder rather than a different struct:
dec := json.NewDecoder(resp.Body)dec.UseNumber()var payload map[string]any
if err := dec.Decode(&payload); err != nil {return err}
id, err := payload["snowflake_id"].(json.Number).Int64()json.Number keeps the literal text until you ask for a type. Typing the field int64 in a struct avoids the problem too, since the standard library parses the digits directly rather than routing them through a float.
time.Time reads RFC 3339 and nothing else
With the time toggle on, a string matching the ISO date shape becomes time.Time, which brings its own UnmarshalJSON. That method accepts RFC 3339 and rejects everything near it. These four all fail:
"2026-08-19 06:41:23" // space instead of T
"2026-08-19T06:41:23+05" // offset needs minutes
"19/08/2026" // not ISO at all
1755585683 // Unix seconds, decodes as intA single bad row fails the whole Unmarshal call with parsing time ... as RFC3339, and nothing else in the payload gets populated. When the upstream format wanders, turn the toggle off, keep the field as a string, and parse it where an error has somewhere to go. Unix timestamps need a named type with a hand written UnmarshalJSON:
type Epoch time.Time
func (e *Epoch) UnmarshalJSON(b []byte) error {var secs int64
if err := json.Unmarshal(b, &secs); err != nil {return err}
*e = Epoch(time.Unix(secs, 0).UTC())return nil}Offsets survive the parse, unlike several other languages. time.Time holds the instant plus a fixed zone, so a value ending +05:00 keeps that offset until you call UTC() or Local(). The zone name is still lost, so a timestamp written in Karachi and one written in Yekaterinburg are indistinguishable afterwards.
Three shapes, and what each one costs
- Struct with json tagsThe default, and the right answer for an API client. Wire keys stay in the tags, field names stay idiomatic, and omitempty keeps optional keys out of anything you marshal back. Drop it in a file, run goimports, done.
- Plain struct, no tagsRelies entirely on the case insensitive fallback, so it works only when every key is a single word or already camelCase. Useful for an internal payload you control on both ends, or as a starting point when the tags are going to be hand written anyway. Any snake_case key silently stops decoding.
- GORM model with column tagsAdds gorm:"column:..." to each field plus CreatedAt and UpdatedAt timestamps GORM fills automatically. An ID field is marked as the primary key. Nested structs and slices are the part to fix by hand, since GORM treats them as relations and wants a foreign key, a join table, or a serializer:json tag to store them in one column.
Where this stops
- An object keyed by IDs, such as
{"svc_4a1c": {...}, "svc_9f07": {...}}, becomes a struct with one field per ID. A map is what you want there. Rewrite the field asmap[string]Serviceby hand, since no single response proves whether keys are data or schema. - String fields holding a fixed set of values stay
string. Nothing in one payload proves the set is closed, so no typed constants are generated. - Embedded structs, interfaces and generics are out of scope. Every type written here is flat and named.
- An empty array gives
[]interface{}, because zero elements carry zero type information. - No
json.RawMessagefor a field you plan to decode later against a discriminator, and no customUnmarshalJSONstubs. - Alignment matches gofmt, though tag content is left alone. Run
gofmtorgoimportsafter pasting, especially if you edit the types. - Pastes over 1 MB are refused rather than locking the tab. Ten representative records beat a full page of results.
Parsing and code generation happen in JavaScript inside this page. A response carrying bearer tokens, invoice rows or customer records never leaves the browser, which is not true of a converter that posts the body to a server first.
Decoding into the generated struct
The types handle the shape. The call around them stays yours, and streaming the body beats reading it into memory when the payload is large:
resp, err := http.Get("https://api.toolexe.com/v1/services")
if err != nil {return nil, fmt.Errorf("service list request: %w", err)}
defer resp.Body.Close()if resp.StatusCode != http.StatusOK {return nil, fmt.Errorf("service list returned %s", resp.Status)}
var services []Service
if err := json.NewDecoder(resp.Body).Decode(&services); err != nil {return nil, fmt.Errorf("decode service list: %w", err)}The generator names the root type from the rail, so an array response decodes into a slice of it. One flag is worth adding while the struct is new:
dec := json.NewDecoder(resp.Body)dec.DisallowUnknownFields()By default a key with no matching field is dropped in silence, which hides both a typo in a tag and a field the API added last week. DisallowUnknownFields turns that silence into an error. Keep it on in tests and in staging, then decide whether a strict production client suits you, since an upstream team adding one harmless key would start failing every request.
