JSON to Go Struct Converter

A Go service will not unmarshal a byte until a typed struct exists, and the upstream docs went stale two releases ago. Paste the response, take the struct, then read the type log underneath to see which keys turned into pointers and what the generator saw to make that call.

CtrlEnter regenerates
JSONone record, or the whole array for a sharper result
Gogofmt aligned, read only

Type log

One line per key: the JSON path, the exported Go name it took, the type it landed on, and the evidence behind the choice. A row you disagree with usually means the paste above is thinner than the real API.

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 key

The 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 int

A 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 as map[string]Service by 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.RawMessage for a field you plan to decode later against a discriminator, and no custom UnmarshalJSON stubs.
  • Alignment matches gofmt, though tag content is left alone. Run gofmt or goimports after 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.

JSON to Go questions

Why did my field decode as empty with no error?

Two usual causes. The field is unexported, so reflection skipped it, or the tag does not match the wire key and the case insensitive fallback found nothing either. Check the first letter is capitalised, then check the tag spelling against the type log above.

Should I paste one record or the whole array?

The array, up to the 1 MB cap. Records are merged before any Go is written, so a key missing from some of them comes back as a pointer with omitempty, and a number sent whole in one row and as a decimal in another widens to float64 instead of panicking later.

When is a pointer worth the nil checks?

When absent and zero mean different things. A count, a price or a boolean flag all have a meaningful zero, so a value type would hide the difference. For a field with a safe default, take the value type and skip the dereference at every read site.

Why is a field typed interface{} instead of string?

Because every record you pasted had null there, or two records disagreed about the type. A null proves the key exists and proves nothing about what it holds. Paste a record where the field carries a real value and the type resolves on the next keystroke.

The int64 IDs come back rounded. What went wrong?

Something decoded into interface{} or map[string]interface{} along the way, where every JSON number becomes a float64 and loses whole number precision past 9007199254740991. Decode into the generated struct with an int64 field, or use json.Decoder with UseNumber for dynamic payloads.

Can it write map[string]Service instead of a struct?

No. An object keyed by IDs looks identical to an object with fixed field names, and one response never settles which it is. Generate the value type, then change the field on the parent struct to a map yourself.

Is my JSON uploaded anywhere?

No. Parsing and generation are JavaScript running in this page, so an authenticated response with production data in it stays in your browser.