JSON to Go Struct: 5 Approaches Compared (2026)
Every Go developer who consumes a new API hits the same wall: how do I get a struct for this JSON? There are five real answers in 2026, each with different trade-offs in struct-tag handling, nesting style, and type inference. Here is how they compare — with working code for each, plus the four struct-tag pitfalls that bite the most.
The example JSON
We will run the same payload through every generator so you can see the exact output each one produces. It covers the patterns that matter — nested objects, arrays, booleans, decimals, integers, and ISO 8601 timestamps.
{
"order": {
"id": "ORD-9921",
"total": 1249.99,
"currency": "INR",
"paid": true,
"items": [
{ "sku": "PROD-441", "quantity": 2, "unit_price": 499.99 }
],
"customer": {
"id": 42,
"email": "jane@example.com"
},
"placed_at": "2026-05-10T09:30:00Z"
}
}Read that carefully. total is a JSON number with a decimal, id is a string, customer.id is an integer, and placed_at is an ISO 8601 timestamp. Each generator handles those types slightly differently.
Approach 1: Manual struct writing
Write the structs by hand, guided by the JSON shape. Slow for 50-field payloads, perfect for small APIs where you want every type chosen on purpose.
package main
import "time"
type Item struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
}
type Customer struct {
ID int `json:"id"`
Email string `json:"email"`
}
type Order struct {
ID string `json:"id"`
Total float64 `json:"total"`
Currency string `json:"currency"`
Paid bool `json:"paid"`
Items []Item `json:"items"`
Customer Customer `json:"customer"`
PlacedAt time.Time `json:"placed_at"`
}
type Response struct {
Order Order `json:"order"`
}Pros
- Full control over type choice (time.Time vs string, int64 vs int)
- Add Go doc comments at the type level
- Zero dependencies, zero generated code in CI
- Optional fields handled explicitly via pointers
Cons
- ✗ Tedious past 20 fields
- ✗ Drift when the API adds fields
- ✗ Easy to swap a field name and miss it in code review
- ✗ No runtime schema check
Best for: stable internal APIs with 10 fields or fewer, or hot paths where a generated converter function adds latency you measured and care about.
Approach 2: mholt/json-to-go (the original)
mholt.github.io/json-to-go is the canonical online generator, written by Matt Holt and open source since 2015 (github.com/mholt/json-to-go). The page is pure JavaScript — paste JSON on the left, Go on the right, nothing leaves your browser. Toggles for omitempty and inline types are right there in the header.
Output for our example JSON:
// Generated by mholt/json-to-go
type AutoGenerated struct {
Order struct {
ID string `json:"id"`
Total float64 `json:"total"`
Currency string `json:"currency"`
Paid bool `json:"paid"`
Items []struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
} `json:"items"`
Customer struct {
ID int `json:"id"`
Email string `json:"email"`
} `json:"customer"`
PlacedAt time.Time `json:"placed_at"`
} `json:"order"`
}Pros
- Browser-only, JSON never sent to a server
- Live preview as you type
- Open source — fork it and run offline
- Detects ISO 8601 strings and emits time.Time
Cons
- ✗ Single-sample inference — optional fields missed
- ✗ No CLI for build-pipeline integration
- ✗ Default is anonymous nested structs, harder to reuse
- ✗ No control over package name or top-level type name
Best for: the one-off case. You have a JSON response, you want a struct in 10 seconds, you do not need to wire generation into CI.
Approach 3: quicktype (CLI + web)
quicktype.io is the most widely used multi-language type generator in 2026. The web UI is convenient; the CLI is what matters for repeat use. quicktype supports 20+ output languages from the same JSON or JSON Schema input, and it is the only generator on this list that handles multi-sample inference well.
Output for our example JSON:
// Generated by quicktype (quicktype.io)
type Response struct {
Order Order `json:"order"`
}
type Order struct {
ID string `json:"id"`
Total float64 `json:"total"`
Currency string `json:"currency"`
Paid bool `json:"paid"`
Items []Item `json:"items"`
Customer Customer `json:"customer"`
PlacedAt time.Time `json:"placed_at"`
}
type Item struct {
Sku string `json:"sku"`
Quantity int64 `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
}
type Customer struct {
ID int64 `json:"id"`
Email string `json:"email"`
}CLI usage:
# Install quicktype globally npm install -g quicktype # Generate Go structs from a JSON file quicktype --lang go --src response.json --out types.go # Generate from a URL (quicktype fetches the JSON) quicktype --lang go --src-lang json \ https://api.example.com/order/9921 --out types.go # Custom package name + top-level type quicktype --lang go --package api --top-level Response \ --src response.json --out types.go
--src sample1.json --src sample2.json --src sample3.json and quicktype widens the inferred types to cover all three. A field that appears in sample 1 and 2 but not 3 becomes optional. This is the single biggest reason to pick quicktype over the simpler online tools when you have access to more than one response.Pros
- Multi-sample inference produces widened types
- Named struct types by default, easier to maintain
- CLI integrates into build pipelines and pre-commit hooks
- Configurable package name and top-level type name
Cons
- ✗ Heavy npm install for one-off use
- ✗ Defaults to int64 even where int would do
- ✗ Generated converter functions add a runtime dependency
- ✗ Date detection less aggressive than mholt/json-to-go
Best for: generating Go types as part of a build pipeline, especially when the same JSON shape needs TypeScript or Python types too.
Approach 4: JSONLint json-to-go
jsonlint.com/json-to-go wraps a similar single-sample generator on top of their JSON validator. The output is named structs (not inline anonymous like mholt), and tags are produced with omitempty by default on optional-looking fields.
Output for our example JSON is functionally identical to quicktype's, with two differences: JSONLint emits int instead of int64 for small integer fields, and it emits string for ISO 8601 timestamps instead of time.Time. If you prefer to parse timestamps explicitly in a constructor, that default works for you. If you want zero-cost time.Time, you will be doing a find-and-replace.
Approach 5: gojson CLI (ChimeraCoder)
github.com/ChimeraCoder/gojson is a stdin-friendly Go CLI tool. Install it with go install, then pipe JSON into it. It has been around since 2014 and is the choice of teams that already have Go on PATH and do not want to add a Node toolchain.
# Install gojson (ChimeraCoder) go install github.com/ChimeraCoder/gojson/gojson@latest # Pipe JSON to stdin cat response.json | gojson -name Response -pkg api > types.go # From a remote URL curl -s https://api.example.com/order/9921 | gojson -name Response > types.go
The output is named structs with json tags, similar to quicktype. gojson is single-sample only — no multi-sample inference — but it is the lightest install path when you already have a Go environment and need to script generation in a Makefile or shell script.
Four struct-tag pitfalls every Go developer hits
1. Typos in the tag key are silently ignored
The most painful one. encoding/json does not validate tag keys. A typo like josn:"name" falls back to the capitalized Go field name.
type Order struct {
ID string `josn:"id"` // typo: "josn" — silently ignored, uses field name "ID"
Total float64 `json:"total"`
}
// Marshals as: {"ID":"ORD-9921","total":1249.99}
// Camel-case field name leaks into JSON output. Linters like govet --json catch this.go vet catches some of these via the structtag analyzer, but only for known tag keys. Treat go vet as a CI gate; do not rely on review.
2. Unexported fields are invisible to encoding/json
internalID with lowercase first letter is unexported. The marshaler cannot see it, the unmarshaler cannot fill it. No error. The fix is to export it (InternalID) and add a json tag if you want a different on-the-wire name.
3. omitempty drops false the same as absent on bools
On a bool field, omitempty means "skip when false." If you need to distinguish "explicit false" from "absent," use *bool:
type Customer struct {
ID int `json:"id"`
Email string `json:"email"`
Phone string `json:"phone,omitempty"` // omits "" from output
Verified *bool `json:"verified,omitempty"` // pointer keeps false distinct from absent
}4. Embedded struct field promotion has rules you should know
An anonymous embedded struct gets its exported fields promoted to the outer struct's JSON. Useful for sharing a BaseEntity across types:
type BaseEntity struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
}
type Order struct {
BaseEntity // anonymous embedded — fields promoted at the top level
Total float64 `json:"total"`
Currency string `json:"currency"`
}
// JSON output:
// { "id": "...", "created_at": "...", "total": 1249.99, "currency": "INR" }Gotcha: embed an unexported struct and its fields will not be promoted (golang/go#54322 documents the asymmetry between embedded structs and embedded interfaces). And if the embedded struct has a json tag of its own, the inner fields are nested under that tag's name instead of promoted — that is sometimes what you want, but rarely what an auto-generator produces.
Comparison table
| Approach | Multi-sample | Privacy | CLI | Best for |
|---|---|---|---|---|
| Manual | N/A | Local | N/A | Small, stable APIs |
| mholt/json-to-go | No | Browser only | No | One-off conversion |
| quicktype | Yes | Local (CLI) | Yes (npm) | Build pipelines |
| JSONLint | No | Server-side | No | Public JSON only |
| gojson | No | Local (CLI) | Yes (go install) | Go-only toolchains |
Which one should you use?
Three rules of thumb:
- One-off, small payload, browser-friendly: mholt/json-to-go. Paste, copy, done.
- Repeat use or part of a build: quicktype CLI. Multi-sample widening is the killer feature.
- Go-only toolchain, no Node: gojson via
go install.
Skip JSONLint if your JSON is sensitive. Skip the generators entirely and write structs by hand if the API is small and stable and you want every type chosen on purpose.
Need to clean up the JSON first?
PDF Mavericks runs every developer tool in your browser. Format, validate, and inspect your JSON without uploading it anywhere — then paste the cleaned-up version into your generator of choice.
Frequently asked questions
What is the difference between json to go struct generators?
The four common online generators differ in nesting style and tagging defaults. mholt/json-to-go produces anonymous nested structs in one block — fast to paste, hard to reuse the inner types elsewhere. quicktype, JSONLint, and gojson produce separate named struct types, which is easier to maintain when the same shape appears in multiple endpoints. quicktype also supports multi-sample inference: feed it three JSON samples and it widens the types correctly. mholt/json-to-go and JSONLint infer from one sample, so an optional field that is absent in your sample will be missed.
Should I use json.Number or float64 for monetary values?
Neither, for production. Use a fixed-point decimal type like shopspring/decimal or your own int64-of-paise representation. float64 loses precision past 15 significant digits — a total like 1249.99 in JSON round-trips fine, but sums of many such values drift. json.Number preserves the original string so you can re-parse later, but doesn't let you do arithmetic. The online generators all default to float64 for any numeric field, which is fine for analytics payloads and wrong for invoices, payments, or any audited ledger.
How do I handle JSON fields that are sometimes a string and sometimes a number?
Generators cannot infer this from a single sample. Use json.RawMessage on the field and post-process, or write a custom UnmarshalJSON that tries each type. quicktype handles this if you feed it multiple samples — it generates an interface{} field and a converter function. For one-off payloads from a third-party API that flips types on you, the json.RawMessage pattern is the least painful: keep the raw bytes and decode in a second pass based on length or first byte.
Why does my struct unmarshal silently lose fields?
Four common causes. First, the field is unexported (lowercase first letter) and encoding/json cannot see it. Second, the tag has a typo like josn or jsoon — Go does not validate tag keys, so the field falls back to its capitalized Go name. Third, the JSON key has a different case than the struct tag and the decoder is case-insensitive only by default — so name and Name both work, but name and nAme also both work, which can hide bugs. Fourth, the field type does not match — a number coming as a JSON string yields a zero value with no error unless you use a strict decoder via DisallowUnknownFields plus a custom UnmarshalJSON.
Is it safe to paste production JSON into an online converter?
Depends on the tool. mholt/json-to-go and PDF Mavericks tools run the conversion in your browser using JavaScript — the JSON never leaves the page. quicktype's web playground claims local-only processing as well. JSONLint's converter posts to their server. If your JSON contains API keys, customer PII, or anything covered by NDA, run quicktype as a CLI tool locally (npm install -g quicktype), or use the mholt source on GitHub offline. The safest pattern: scrub the sample down to one record with fake data before pasting anywhere.
How do I generate Go structs from a JSON Schema instead of a sample?
Use atombender/go-jsonschema (formerly known as gojsonschema-codegen). Sample-based generators infer types from one example and miss optional fields, unions, and constraints. JSON Schema input gives you the full type contract — required vs optional, enums, numeric ranges, and pattern validation as comments. The trade-off is you need a schema, which most third-party APIs don't ship. For internal APIs where you control both ends, JSON Schema as the source of truth and generated Go + TypeScript types from the same schema eliminates an entire class of drift bugs.
What is the omitempty tag and when should I use it?
omitempty tells encoding/json to skip the field when marshaling if the value is the zero value for its type — empty string, 0, nil slice, nil map. Use it on outbound payloads where empty fields would be noise, especially for optional fields the receiving API treats differently than absent ones. Watch out for bool fields: omitempty on a bool drops false the same way it drops absent, so you lose the distinction. The fix is to use *bool — a nil pointer means absent, &falseVal means explicit false.