1. Why think about JSON ↔ Go type mapping
When you first see JSON, it is tempting to treat it as “a string with brackets”. In small examples, that can even work. But as soon as JSON becomes your application’s input/output (file, network, API), it turns into a contract: the data shape must be understood and stable, otherwise the program will break at the worst possible moment—usually during a demo.
In Go, there is the standard package encoding/json, which can convert JSON into Go values and back. But the key idea is this: the same JSON can be “read” using different Go types, and the chosen type determines how safe, clear, and convenient your code will be for the subsequent logic.
So that we do not write an app in the style of “let’s make everything any and figure it out later”, today we will build a “map of the terrain”: which JSON shapes best correspond to struct, []T, and map[string]any, and how these choices differ.
JSON has 6 types
Beginners often think JSON is “anything goes” and that it can contain whatever. In reality, JSON is very strict: it consists of only six types. This is good news, because if there are few types, there are few mapping rules to Go types as well. Everything else is just combinations.
Here is a compact mapping table (not the only possible one, but the most practical to start with):
| JSON type | Example | Typical Go type (when the shape is known) | Typical Go type (when the shape is unknown) |
|---|---|---|---|
| object | |
|
|
| array | |
|
|
| string | |
|
|
| number | |
|
most often it will be inside |
| boolean | |
|
|
| null | |
often |
|
Now we will primarily analyze three “container” cases: JSON object → struct or map, JSON array → []T. These are the choices that define your data-reading architecture: whether you live in a world of types or in a world of “checks at every step”.
2. JSON object → struct: when the data shape is known
When a JSON object has a clear structure (for example, a task in a tracker: id, title, done), it is most reasonable to decode it into a struct. It is like a form: a form has fields defined in advance, and if you start storing it as a “bag of keys and values”, you make your life harder. A struct gives the compiler a chance to protect you from silly mistakes.
It is important to remember one reflection-related detail in Go: encoders/decoders generally work only with exported struct fields (i.e., fields starting with an uppercase letter). You will see this idea in other standard serialization packages as well; for example, the encoding/gob docs explicitly emphasize that only exported fields are encoded and decoded. The same rule applies when working with JSON.
Let’s start building our learning app: a “mini task tracker”. For now, without files and without HTTP—we will just learn how to represent a task correctly in code.
Mini model Task and decoding JSON into a struct
Let’s create a struct and try to parse input JSON. Note: JSON uses id, title, done, while in Go we use ID, Title, Done. For basic cases, encoding/json can match names quite flexibly (but “complex” names like created_at are a separate story).
package main
type Task struct {
ID int
Title string
Done bool
}
Now a function that decodes JSON into Task. The key idea here: decoding happens into a variable by address, because the library needs a place to write the result.
package main
import (
"encoding/json"
"fmt"
)
func parseTask(data []byte) (Task, error) {
var t Task
if err := json.Unmarshal(data, &t); err != nil {
return Task{}, fmt.Errorf("parse task: %v", err)
}
return t, nil
}
And a small usage example:
package main
import "fmt"
func main() {
t, err := parseTask([]byte(`{"id":1,"title":"read docs","done":false}`))
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%+v\n", t) // {ID:1 Title:read docs Done:false}
}
Notice the feeling: after Unmarshal, you have a normal Go struct in your hands, and you can work with it without “dancing” around types.
Nested objects: struct inside struct
In real JSON, objects are often nested: for example, a task might have an author. In Go, it is convenient to model this with a nested struct.
package main
type Author struct {
Name string
}
type Task struct {
ID int
Title string
Done bool
Author Author
}
If the input is:
{"id":2,"title":"write code","done":true,"author":{"name":"Sam"}}
then after Unmarshal you will get Task{Author: Author{Name:"Sam"}}. This is one reason why struct is your best friend when the input structure is known.
Partial decoding: take only the fields you need
Sometimes you do not need “everything”. For example, you want to quickly check that id exists, and the rest is not important yet. Then you can create a receiver struct with a minimal set of fields.
package main
import "encoding/json"
type TaskIDOnly struct {
ID int
}
func parseTaskID(data []byte) (int, error) {
var x TaskIDOnly
if err := json.Unmarshal(data, &x); err != nil {
return 0, err
}
return x.ID, nil
}
This is a very practical technique: a small task-specific struct makes code more readable than decoding everything into map[string]any and doing manual checks.
3. JSON array → []T: when there is a lot of data and it is homogeneous
A JSON array is a sequence of elements. In Go, the natural representation for a sequence is a slice []T. The rule here is simple: you choose a type T, and by doing so you define how each array element should be decoded. If T is a struct, then each array element must be a JSON object of the appropriate shape.
This is like a box of identical parts: if you expect “M6 bolts” inside, you do not want to suddenly find “a banana peel” there (even though JSON can theoretically do that). With []T, you ask the decoder to validate the shape for you.
Decoding an array of tasks: []Task
Suppose we received a list of tasks:
[
{"id":1,"title":"read docs","done":false},
{"id":2,"title":"write code","done":true}
]
In Go, this is decoded as []Task:
package main
import (
"encoding/json"
"fmt"
)
func parseTaskList(data []byte) ([]Task, error) {
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("parse task list: %v", err)
}
return tasks, nil
}
And usage:
package main
import "fmt"
func main() {
data := []byte(`[{"id":1,"title":"A","done":false},{"id":2,"title":"B","done":true}]`)
tasks, err := parseTaskList(data)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(len(tasks)) // 2
}
An array of simple values: []string, []int
If a JSON array consists of strings, it is convenient to decode it into []string. For example, task tags:
["go","json","practice"]
package main
import "encoding/json"
func parseTags(data []byte) ([]string, error) {
var tags []string
err := json.Unmarshal(data, &tags)
return tags, err
}
This example looks almost comically simple, but that is the beauty of it: the right type choice makes the code shorter and the errors earlier.
nil slice vs empty slice
Slices have two “empty” states: nil and []T{} (empty, but not nil). In everyday logic this usually does not matter, but it can show up during serialization and comparisons. For a beginner, it is enough to remember: if a function returns []T, it is often convenient to return nil, err on error and a normal slice (possibly empty) on success.
If you are just starting out, do not try to “invent complicated rules”. The only important thing is to consistently follow your chosen contract: either “empty list = empty slice”, or “empty list may be nil”. The main thing is not to mix them—one way in one place and another way elsewhere.
4. JSON object → map[string]any: when the shape is unknown
Sometimes the JSON structure is not known in advance. For example, you are building a debugging utility that accepts “any JSON” and prints what is inside. Or you are writing code that must pass through “user-defined fields” without fixing the key set ahead of time.
Then you choose map[string]any (in older versions people often wrote map[string]interface{}—it means the same thing). But you pay for flexibility: values of type any appear inside, which means you will have to do checks and type assertions manually afterward.
And one more hard fact: keys in a JSON object are always strings, so map[int]... does not fit by definition.
Decode JSON into map[string]any and inspect the types
Let’s take JSON:
{"id":1,"title":"A","done":true}
Decode it:
package main
import (
"encoding/json"
"fmt"
)
func main() {
var m map[string]any
_ = json.Unmarshal([]byte(`{"id":1,"title":"A","done":true}`), &m)
fmt.Printf("%T\n", m["title"]) // string
fmt.Printf("%T\n", m["done"]) // bool
fmt.Printf("%T\n", m["id"]) // float64
}
Surprise: id became float64. This is not a bug and not “mocking students”; it is a consequence of the fact that a JSON number by itself does not say whether it is an int or a float. When you decode into a “dynamic” structure (any), the package chooses the standard representation for numbers: float64.
So the rule is simple: map[string]any is suitable when you are ready to live in a world of manual checks.
Extract a value from map[string]any safely
If you still choose this path, do extraction carefully: first check that the key exists, then check the type.
package main
import "fmt"
func getTitle(m map[string]any) (string, error) {
v, ok := m["title"]
if !ok {
return "", fmt.Errorf("title is missing")
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("title must be string")
}
return s, nil
}
Yes, this is longer than t.Title on a struct. But it is honest: you explicitly formalize the input rules and get clear errors.
Nested objects and arrays in map[string]any
If JSON contains nested objects and arrays, in the dynamic form it will be a recursive structure composed of:
- map[string]any for objects,
- []any for arrays,
- string, bool, float64, nil for primitives.
In a sense, this is “a tree with any nodes”, and your code turns into a tree traversal with many checks. This is fine for debugging and “universal” tools, but it is usually not what you want in business logic.
5. How to choose: struct or map, and where []T fits
In a real project, choosing a type is not a matter of “taste”, but an engineering decision. You need to understand what you want: strictness and readability, or “we accept anything” flexibility.
Imagine JSON is a package, and a Go type is the receiving form at a warehouse. If you know you are receiving smartphones, you create a form with lines for “serial number”, “model”, “color”. If you do not know what will arrive, you write “received: a box, contents to be determined”—and it is harder to process later.
Here is a handy comparison table:
| Criterion | |
|
|---|---|---|
| Type safety | high | low (everything via checks) |
| Code readability | usually excellent | quickly becomes noisy |
| Key flexibility | fixed shape | any keys are possible |
| Errors | often caught earlier | caught later, at runtime |
| Ideal for | API contracts, DTOs, models | debugging, “plugin-style” data, unknown schema |
And []T is simply “many entities of the same kind”. So most often you choose either []Task or []map[string]any, depending on whether the element shape is known.
Decoding errors: broken JSON is normal
While learning, it is very tempting to write _ = json.Unmarshal(...) and “not think about the bad stuff”. But external JSON can almost always be invalid: the user made a mistake, the file got truncated, the network hiccuped, or someone simply sent you not JSON but a love letter in the “almost JSON” format.
encoding/json has specific error types for some cases. For example, syntax problems during parsing are reported as json.SyntaxError, and it has an Offset field that indicates the byte position where everything broke. This idea (and the type itself) is often used to make error messages more helpful.
We do not need to go deep into extracting line/column here—it is enough to build a habit: a decoding error is not “shame”, it is a normal code path. You check err and proceed: either inform the user, or stop processing.
6. Typical mistakes when choosing and using Go types for JSON
Mistake #1: decoding a known JSON object into map[string]any “just in case”.
This solution feels flexible, but in practice it quickly turns code into a swamp of checks, type assertions, and unclear errors. If the structure is known and stable, struct is almost always simpler, shorter, and more reliable: fields are already typed, and the compiler helps you avoid mixing up string and bool.
Mistake #2: expecting map[string]any to keep numbers as int.
With dynamic decoding, numbers usually become float64, which breaks naive assertions like m["id"].(int). You must either decode into a struct with an int field, or deliberately convert float64 → int with checks (and remember that 3.14 in id is actually a reason to complain).
Mistake #3: using map[int]any for a JSON object.
In JSON, object keys are strings. Always. Even if a key “visually” looks like a number ({"1":"a"}), it is still the string "1". Therefore, the correct container for an object is map[string]....
Mistake #4: forgetting to pass an address to Unmarshal.
json.Unmarshal(data, t) cannot fill t, because it needs a “write target”. Correct: json.Unmarshal(data, &t). If you see an error or strange behavior, first check whether you forgot the &.
Mistake #5: making struct fields unexported and being surprised they are not populated.
If you wrote type Task struct { title string }, then during decoding the title field will not be set, because it is not exported. You end up with an “always empty title” and start suspecting a JSON conspiracy. In reality, it is enough to write Title string. You should hide fields intentionally and according to the model’s rules—not accidentally.
GO TO FULL VERSION