go
JSON to Go Struct: What the Converter Can't Know
A JSON to Go struct converter sees one sample. Why it emits int64 for every whole number, interface{} for null, and never time.Time — and exactly what to fix.
You paste an API response into a JSON-to-Go converter, drop the struct into your handler, and a week later json.Unmarshal starts silently leaving a field at its zero value — because the price that was 12 in your sample arrived as 12.5, and Go skipped it. A converter is looking at one document, and a single JSON document cannot tell it the difference between “this field is an integer” and “this field happened to be a round number that Tuesday.” Everything a generated struct gets wrong is a guess it had no evidence to make, and all of those guesses live in about eight places.
This post runs one realistic sample through our JSON to Go struct converter, shows the output verbatim, and goes through each guess with the encoding/json rule that decides whether it bites you. It is the Go half of Generating JSON Schema From a Sample: What Inference Can’t Know — same blind spots, different target language, and Go’s static types make several of them fail louder than a schema would.
What the converter actually emits
The whole type mapping is short enough to print:
| Value in sample | Emitted Go type |
|---|---|
| string | string |
| number with no fractional part | int64 |
| number with a fractional part | float64 |
true / false | bool |
null (and nothing else ever seen for that key) | interface{} |
| object | a named struct, ParentKey in PascalCase |
| array, all elements agree | []T |
| empty array, or elements that disagree | []interface{} |
Field names are the JSON key split on non-alphanumerics and PascalCased — Go only marshals exported fields, so this step is mandatory, not cosmetic. Each field gets a `json:"original_key"` tag (toggle “Serialization annotations” off if you don’t want them). A field gets a pointer type when the sample shows it is nullable or sometimes-absent — except for arrays and interface{} fields, which are already nil-able and are left alone — and ,omitempty when it is sometimes-absent. Everything below follows from that.
The sample
{
"id": 412,
"display_name": "ada",
"status": "active",
"price": 12,
"avatar_url": null,
"tags": [],
"limits": { "proj_1": 5, "proj_2": 20 },
"owner": { "id": 7, "email": "[email protected]" },
"created_at": "2026-09-09T09:30:00Z"
}
Root name Account, output verbatim:
type Account struct {
Id int64 `json:"id"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
Price int64 `json:"price"`
AvatarUrl interface{} `json:"avatar_url"`
Tags []interface{} `json:"tags"`
Limits AccountLimits `json:"limits"`
Owner AccountOwner `json:"owner"`
CreatedAt string `json:"created_at"`
}
type AccountLimits struct {
Proj1 int64 `json:"proj_1"`
Proj2 int64 `json:"proj_2"`
}
type AccountOwner struct {
Id int64 `json:"id"`
Email string `json:"email"`
}
Nine fields, and six of them are decisions you still have to make.
null is not a nullable type
"avatar_url": null gives the converter one fact: this key exists and its only observed value was null. With no candidate type to make a pointer to, it emits interface{} — not *string. That compiles, unmarshals anything, and pushes a type assertion into every call site.
Go’s decoder makes this worse than it looks, because null and absent are the same thing to it. From the Unmarshal docs: “To unmarshal JSON into a pointer, Unmarshal first handles the case of the JSON being the JSON literal null. In that case, Unmarshal sets the pointer to nil.” Unmarshalling a null into any other Go type “has no effect on the value and produces no error.” So *string gives you nil for both a null and a missing key, and plain string gives you "" for both. If those two cases mean different things in your domain, no struct field distinguishes them; you need json.RawMessage, a custom UnmarshalJSON, or a sql.NullString-style wrapper with an explicit “was it there” flag.
Give the converter more evidence and it does better. Paste an array of two objects instead:
[
{ "id": 412, "avatar_url": null, "price": 12 },
{ "id": 413, "avatar_url": "https://x/y.png", "price": 12.5, "note": "hi" }
]
type Account struct {
Id int64 `json:"id"`
AvatarUrl *string `json:"avatar_url"`
Price float64 `json:"price"`
Note *string `json:"note,omitempty"`
}
Now avatar_url is *string (nullable, always present), price widened to float64 because one element had a fraction, and note got both a pointer and ,omitempty because it was missing from one element. The fastest way to improve generated Go is to paste several records instead of one — every disagreement the inference sees is a guess it no longer has to make.
Every whole number becomes int64, including 1.0
The converter classifies a number by whether it has a fractional part after parsing, and JSON.parse("1.0") yields the number 1. So {"price": 1.0} and {"price": 1} produce identical structs: int64.
Go’s decoder is strict about this in a way that is easy to miss. In literalStore in encoding/json/decode.go, an integer target is filled with:
n, err := strconv.ParseInt(string(item), 10, 64)
if err != nil || v.OverflowInt(n) {
d.saveError(&UnmarshalTypeError{Value: "number " + string(item), ...})
break
}
Base 10, on the raw literal text. "1.0" does not parse, so it is an UnmarshalTypeError — and saveError means the field is skipped and left at zero, with Unmarshal returning that error only at the end. Ignore the error, or log it and carry on, and you get a silent 0. That is the bug in the first paragraph.
Two consequences worth internalizing:
- Widen anything that could ever carry a fraction. Money, ratios, durations, scores, latencies.
int64in generated output means “this was round once,” not “this is an integer.” int64is genuinely the right guess for IDs, though. Because Go parses the literal text rather than going through a float,9223372036854775807lands in anint64exactly — no precision loss. The converter’s own view of that number is already wrong (the browser parsed it as an IEEE 754 double, so anything above 2^53−1 = 9007199254740991 was rounded before inference ran), but the emitted type is still correct. Whatint64cannot hold is auint64ID above 9.22×10^18, or exponent form like1e21, whichParseIntrejects outright. For an unbounded ID space, usejson.Numberorstring.
json.Number is the escape hatch for “this field is a number and I refuse to commit”: it is a string type with Int64(), Float64(), and String() methods, and Decoder.UseNumber() makes the decoder use it for numbers landing in an interface{} instead of the default float64.
omitempty does not mean optional (and omitzero is the fix)
The converter writes ,omitempty on fields it saw go missing. That is the right signal and often the wrong behavior, because omitempty is defined on the Go value, not on presence. From the Marshal docs: the field is omitted if it has “an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero.”
Read that list for what is missing. A zero struct is not empty; neither is a zero time.Time. And a legitimate 0, false, or "" is — so omitempty on a plain int quantity silently drops the value 0 from your output, exactly when a consumer most needs to see it.
Go 1.24 added omitzero, and the release notes are explicit about why: a field with omitzero “will be omitted if its value is zero. If the field type has an IsZero() bool method, that will be used to determine whether the value is zero” — and “unlike omitempty, omitzero omits zero-valued time.Time values, which is a common source of friction.” If both tags are present, the field is omitted if the value is either empty or zero.
So the three states, once you have decided which one you actually have:
| What you mean | Field | Tag |
|---|---|---|
| Always present, never null | string | json:"name" |
| Present, may be null | *string | json:"avatar_url" |
| May be absent entirely | *string | json:"note,omitempty" |
Struct or time.Time you want dropped when unset | time.Time | json:"seen_at,omitzero" (Go 1.24+) |
A 0 / false / "" that must survive round-tripping | int | json:"count" — no omitempty |
Field names: UserId, not UserID
Splitting on non-alphanumerics and capitalizing each part gives user_id → UserId and api_url → ApiUrl. Go’s conventions — and go vet’s linting cousins — want UserID and APIURL for initialisms. Nothing breaks: the `json:"user_id"` tag carries the wire name, so serialization is correct either way. Fix the casing for the reviewer, not the compiler.
Two related notes. Keys starting with a digit get a T prefix ("2fa" → T2fa), since Go identifiers can’t start with one. And on the decode side the tag matters less than you think: Unmarshal matches keys “preferring an exact match but also accepting a case-insensitive match,” so DisplayName picks up displayname on its own. Tags are what make your output match the API’s casing.
Objects that are really maps
"limits": {"proj_1": 5, "proj_2": 20} is a dictionary keyed by project ID, and the converter turned it into a struct with fields named Proj1 and Proj2. The next response has proj_3 and your struct silently ignores it.
No sample-based tool can tell a record from a map — both are JSON objects. The heuristic is human: if the keys are data (IDs, dates, locales, feature names), you want map[string]int64; if the keys are schema (id, email, created_at), you want a struct.
Arrays with nothing in them
"tags": [] becomes []interface{} because there is no element to infer from, and an array whose elements disagree ([1, "two", true]) becomes []interface{} too. The first is missing evidence; the second is real heterogeneity Go has no clean type for. Fix the first by supplying a sample with elements. For the second you’re writing a custom UnmarshalJSON on a named type either way — []any (the Go 1.18+ alias, identical to interface{}) just defers the work to runtime.
Dates are strings, and enums are strings
"created_at": "2026-09-09T09:30:00Z" is emitted as string, deliberately. Changing it to time.Time is usually right — but only if the producer emits RFC 3339, because that is the only format time.Time speaks. Its MarshalJSON produces “a quoted string in the RFC 3339 format with sub-second precision” and UnmarshalJSON expects the same. Unix seconds, 2026-09-09, and 2026-09-09 09:30:00 (space instead of T) all need a custom type with your own UnmarshalJSON.
"status": "active" has the same shape of problem: one value is not a set. type Status string with declared constants costs four lines and turns a typo into a compile error.
The full table
| JSON in the sample | Converter’s guess | What you decide |
|---|---|---|
null only | interface{} | The real type, then *T |
12 | int64 | Could it ever be 12.5? → float64 |
| Large ID | int64 | Above int64 range? → json.Number or string |
| Key absent in some records | *T + ,omitempty | omitempty vs omitzero vs neither |
"2026-09-09T09:30:00Z" | string | time.Time if RFC 3339, else custom type |
"active" | string | Named type + constants |
[] or mixed array | []interface{} | Element type, or custom UnmarshalJSON |
| Object with ID-like keys | Nested struct | map[string]T |
user_id | UserId | UserID for the linter |
What to do
Paste several records, not one — an array of three real responses resolves nullability, optionality, and int-vs-float in a single step, and if your sample is a log stream remember that NDJSON is not a JSON array and needs wrapping first. Then walk the output for interface{}, int64, and []interface{}: those three tokens are the converter telling you it ran out of evidence. Add omitzero where you mean “unset,” a named type where you mean “enum,” and time.Time only after you’ve checked the timestamp format.
Run your sample through the JSON to Go struct converter — it runs entirely in your browser, nothing is uploaded. Messy input is worth a pass through the JSON formatter first so you can see the shape you’re inferring from. The same inference drives our JSON to Rust, JSON to Kotlin, and JSON to TypeScript converters with the same blind spots in different clothes — C#, Java, GraphQL and JSDoc put the same guesses through four more type systems — and the JSON Schema generator produces the validation-side view of the same guesses — the schema post and the Pydantic post walk through hardening those. In every case the generated output is a typed skeleton, not a contract.