---
title: 'JSON Serialization'
source: 'https://academia.sh/en/courses/go-applications/json-serialization'
course: 'The Standard Library and Network Applications'
language: en
updated: '2026-09-14T10:59:30+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures by running it how struct tags carry field mapping and that a field excluded by a tag is never sent at all'
    - 'Shows why an integer decoded into an untyped container comes back as `float64`, and why a field with no counterpart on the receiver drops silently'
    - "Builds the course's full distribution of thirteen boundary crossings (1 preserved, 5 rebuilt, 7 lost) in a single measurement, laying the foundation for the following lessons"
---

# JSON Serialization

The course's binding number is produced here: of the thirteen boundary crossings, type information is preserved in 1, rebuilt in 5, lost in 7. The schema that carries a struct's field names across is the struct tag; the same byte sequence decoded into an untyped container turns an integer into `float64`, a field with no counterpart on the receiver drops silently, and no error surfaces. The limiting measure: appearing to be rebuilt does not mean it was rebuilt correctly.

The previous lesson measured that a body crossing the boundary carries only bytes, no type
information at all. But that body's **content** was still a question mark — how is a piece
of text like `"qty=7 unit=kg"` interpreted by the requesting side? This lesson takes on
that question directly: if the sending side holds a **struct** in hand, which part of that
struct survives on the receiving side after crossing the boundary? The answer lies not in
the struct itself, but in a **schema** carried along with it — in Go, the name of this
schema is the struct tag.

This lesson also produces the course's binding measurement. The question the course asks is
the same in all twelve lessons: what happens to a value's type information as it crosses a
boundary? So far this question has been asked in two narrow examples (request parts,
response body). This lesson asks the question **at scale** — it runs thirteen network,
template, database, and command-line boundaries one after another in a single measurement
and writes each one into one of three outcomes: **preserved**, **rebuilt**, **lost**. Four of
these thirteen crossings are directly JSON's subject; the remaining nine are each deepened
separately in the rest of the course's lessons, and here they are only classified.

There is a reason this broad measurement is gathered into a single measurement: measuring
the thirteen crossings separately across thirteen lessons would lead each lesson to produce
its own small number, and no one would be able to see what these small numbers add up to
together. Gathering them in a single run lets the course's main claim (that the boundary
mostly loses type information) be proven with a single number. This lesson produces that
number; the remaining eleven lessons each expand one part of that number in turn.

## Struct Tags: The Schema for Field Mapping

A Go struct's field names (`Name`, `Quantity`) are information that exists at compile time
and is also reachable at run time through reflection (`reflect`). But these names start with
a capital letter and follow Go's own naming rule — that is the requirement for a field to be
exported. There is no such rule for JSON's key names; the program on the other side of the
boundary might expect `"name"` in lowercase, or even an entirely different word. The struct
tag (`json:"name"`) carries the schema for the **field mapping** between these two: it says
which Go field corresponds to which JSON key. If there is no tag, the `encoding/json`
package uses the field name as it is; this lesson runs tagged and untagged fields side by
side.

The source below builds a shared helper that counts and prints this measurement's three
outcomes.

```go
// boundary.go — what happens to a value's type information when it crosses a boundary: is it preserved, rebuilt, or lost
package main

import "fmt"

var preserved, rebuilt, lost int

func observe(crossing, outcome, evidence string) {
	switch outcome {
	case "preserved":
		preserved++
	case "rebuilt":
		rebuilt++
	default:
		lost++
	}
	fmt.Printf("%-36s %-10s %s\n", crossing, outcome, evidence)
}

func summary() {
	fmt.Printf("boundary: %d preserved, %d rebuilt, %d lost\n",
		preserved, rebuilt, lost)
}
```

The measurement itself asks the thirteen boundary crossings in order. The first four are
directly JSON's subject: a round trip to the same struct, a field hidden by a tag, decoding
into an untyped container, and a field with no counterpart on the receiver. The remaining
nine (time, template, reader, network, shared schema, driver, command line) are each
deepened separately in the course's later lessons — which one is handled where is written
out one by one below; here they are only written into the same three outcomes.

```go
// measurement.go — thirteen boundary crossings, split into three outcomes
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"text/template"
	"time"
)

type Record struct {
	Name      string    `json:"name"`
	Quantity  int       `json:"quantity"`
	Timestamp time.Time `json:"timestamp"`
	Hidden    string    `json:"-"`
}

// Driver models the type set a database driver carries: only four types.
type Driver struct{ row []any }

func (d Driver) Read(i int) any { return d.row[i] }

func main() {
	fixedTime, _ := time.Parse(time.RFC3339, "2026-08-23T10:00:00Z")
	record := Record{Name: "bolt", Quantity: 12, Timestamp: fixedTime, Hidden: "invisible"}

	fmt.Println("-- what happens to type information at the boundary --")

	// 1-2. JSON round trip: struct -> text -> struct
	data, _ := json.Marshal(record)
	var rebuiltRecord Record
	json.Unmarshal(data, &rebuiltRecord)
	observe("JSON round trip (same struct)", "rebuilt",
		fmt.Sprint("quantity type=", fmt.Sprintf("%T", rebuiltRecord.Quantity), " equal=", rebuiltRecord.Quantity == record.Quantity))
	observe("untagged field (json:\"-\")", "lost",
		fmt.Sprint("did the hidden field come back=", rebuiltRecord.Hidden != ""))

	// 3. JSON -> map: no type schema
	var untyped map[string]any
	json.Unmarshal(data, &untyped)
	observe("JSON round trip (untyped container)", "lost",
		fmt.Sprint("quantity type=", fmt.Sprintf("%T", untyped["quantity"])))

	// 4. Unknown field drops silently
	var partial Record
	json.Unmarshal([]byte(`{"name":"screw","unknown":5}`), &partial)
	observe("field sent, missing on receiver", "lost",
		fmt.Sprint("name=", partial.Name, " error=false"))

	// 5. Time: type -> text -> type
	text := fixedTime.Format(time.RFC3339)
	again, _ := time.Parse(time.RFC3339, text)
	observe("time value to text and back", "rebuilt",
		fmt.Sprint("equal=", again.Equal(fixedTime), " type=", fmt.Sprintf("%T", again)))

	// 6. Template: output is text only
	tmpl := template.Must(template.New("t").Parse("{{.Name}} x{{.Quantity}}"))
	var output bytes.Buffer
	tmpl.Execute(&output, record)
	observe("template output", "lost", fmt.Sprintf("%q (%T)", output.String(), output.String()))

	// 7. Reader/writer: byte level
	content, _ := io.ReadAll(strings.NewReader("bolt"))
	observe("content from a reader", "lost", fmt.Sprintf("%T length=%d", content, len(content)))

	// 8-9. HTTP: body is bytes, the only shared hint is the content type
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(record)
	}))
	defer server.Close()
	response, _ := http.Get(server.URL)
	body, _ := io.ReadAll(response.Body)
	response.Body.Close()
	observe("HTTP body", "lost", fmt.Sprintf("%T length>0=%t", body, len(body) > 0))
	var received Record
	json.Unmarshal(body, &received)
	observe("resolving with the content type hint", "rebuilt",
		fmt.Sprint("content type=", response.Header.Get("Content-Type"), " quantity=", received.Quantity))

	// 10. Contract-based crossing: both sides share the same schema
	schema := []string{"name:string", "quantity:int"}
	sent := encode(schema, record)
	field, kind := decode(schema, sent)
	observe("shared schema crossing", "preserved", fmt.Sprint(field, " ", kind))

	// 11. Driver's type set: narrows to four types, scan rebuilds it
	d := Driver{row: []any{"bolt", int64(12)}}
	observe("driver's carried type set", "lost",
		fmt.Sprintf("quantity comes in as %T", d.Read(1)))
	var quantity int
	scan(d.Read(1), &quantity)
	observe("rebuild with scan", "rebuilt", fmt.Sprint("quantity=", quantity, " type=", fmt.Sprintf("%T", quantity)))

	// 12. Command line: arguments are strings, the parser rebuilds the type
	flagSet := flag.NewFlagSet("measurement", flag.ContinueOnError)
	flagSet.SetOutput(io.Discard)
	count := flagSet.Int("quantity", 0, "quantity")
	flagSet.Parse([]string{"-quantity", "12"})
	observe("command-line argument", "rebuilt", fmt.Sprint("quantity=", *count, " type=", fmt.Sprintf("%T", *count)))

	summary()
}

// encode and decode work with a schema shared by both sides: the type name stays in the schema.
func encode(schema []string, k Record) []string {
	return []string{k.Name, fmt.Sprint(k.Quantity)}
}

func decode(schema []string, fields []string) (string, string) {
	kinds := make([]string, 0, len(schema))
	for _, t := range schema {
		kinds = append(kinds, strings.SplitN(t, ":", 2)[1])
	}
	sort.Strings(kinds)
	return fields[0], strings.Join(kinds, "+")
}

// scan narrows the wide type the driver returns down to the target's type.
func scan(source any, target *int) {
	if v, ok := source.(int64); ok {
		*target = int(v)
	}
}
```

```
-- what happens to type information at the boundary --
JSON round trip (same struct)        rebuilt    quantity type=int equal=true
untagged field (json:"-")            lost       did the hidden field come back=false
JSON round trip (untyped container)  lost       quantity type=float64
field sent, missing on receiver      lost       name=screw error=false
time value to text and back          rebuilt    equal=true type=time.Time
template output                      lost       "bolt x12" (string)
content from a reader                lost       []uint8 length=4
HTTP body                            lost       []uint8 length>0=true
resolving with the content type hint rebuilt    content type=application/json quantity=12
shared schema crossing               preserved  bolt int+string
driver's carried type set            lost       quantity comes in as int64
rebuild with scan                    rebuilt    quantity=12 type=int
command-line argument                rebuilt    quantity=12 type=int
boundary: 1 preserved, 5 rebuilt, 7 lost
```

**ND14.** JSON's own share is the first four lines. First, on the round trip to the same
`Record` struct, the `Quantity` field comes back with its `int` type and value preserved —
in the `rebuilt` class, because what rebuilds the type is not the struct itself but the
sending and receiving side sharing **the same Go type**. Second, the `Hidden` field is never
sent at all because of the `json:"-"` tag: since it never enters the byte sequence, it does
not come back either — in the `lost` class, but the reason is not a loss, it is a deliberate
exclusion. Third and fourth arise from the same byte sequence (the `data` variable) being
decoded in two different forms.

**ND15.** The third line is this lesson's most striking observation: when the same `data`
byte sequence is decoded into a `map[string]any` variable, the `Quantity` field's source
value `12`, an `int`, comes back with a type of `float64`. The reason is that the JSON
format itself has no distinction between an integer and a decimal number — when the
`encoding/json` package is given no target type (`any` or `map[string]any`), it decodes
every number to `float64` by default. The struct tag plays no role here, because the target
itself is not a struct; the type schema only works when the target is a struct. The fourth
line measures the exact opposite situation: the byte sequence sent contains an `"unknown"`
key with no counterpart in the `Record` struct, and `json.Unmarshal` silently ignores it —
it returns no error, prints no warning, the line `name=screw error=false` confirms this.

**ND16.** What these four lines have in common is that the `encoding/json` package has a
**forgiving** design: a missing field, an extra field, a target with no type schema — none
of them produce an error. This forgiveness sharpens the lesson's opening claim: an operation
that looks "rebuilt" might not have been built correctly. The call
`json.Unmarshal([]byte(...), &partial)` returns a `nil` error; the code looks like it fails
nowhere — but there is no trace, anywhere in the `partial` variable, of the `"unknown"`
field that was sent. The only way for a program to notice this silence is to **manually
compare** what was sent against what was received; neither the compiler nor the runtime
reports this difference on its own.

## The Remaining Nine Crossings: The Course's Full Scale

The section from the measurement's fifth line to its thirteenth line is the subject of the
course's remaining lessons. The fifth line measures a time value turning into text and
coming back — the Time Operations lesson of the System and Data Access topic deepens this.
The sixth line measures that a template's output is text only — the next lesson takes on
this line by itself. The seventh and eighth lines measure the reader/writer abstraction and
the HTTP body; both were or will be handled separately in the previous two lessons and in
the System topic's first lesson. The ninth line shows how the content type header is used as
a hint — the one-line counterpart, here, of the subject measured in detail in the first
lesson.

The tenth line is the course's **only** `preserved` line: the `encode` and `decode`
functions read the same `schema` slice, so no ambiguity remains between the field names sent
and the field names decoded. This line is deepened in the Contract-Based Service
Communication lesson — there, how the schema is **distributed** to both sides is measured
separately, because in this measurement the schema already sits in the same source file on
both sides, and the distribution problem is not modeled. The eleventh and twelfth lines
measure a database driver's narrow type set and how that set is rebuilt with `scan` — the
subject of the Database Access lesson. The thirteenth line measures how a command-line flag
is rebuilt from a string to its target type — the subject of the Command-Line Applications
lesson.

**ND17.** None of these nine lines is handled in detail here; each will be taken up again in
its own lesson, with its own measurement and its own limiting observation. Here, the only
function of these lines is to **complete** the course's binding number: JSON's four lines (0
preserved, 1 rebuilt, 3 lost) do not give the course's full picture on their own, because
the course's only example of the `preserved` class is outside JSON, in the shared-schema
crossing. The total that emerges when all thirteen lines run together — 1 preserved, 5
rebuilt, 7 lost — is used as a fixed reference for the rest of the course from this lesson
onward.

Another reason these nine lines stand here in the measurement is that the examples depend
on each other: the eighth and ninth lines (`HTTP body`, `resolving with the content type
hint`) use the same `record` variable, so they share the same data as JSON's four lines.
This sharing is not accidental — because the course's twelve lessons are separate views of
the same event (a value crossing a boundary), passing the same sample data through different
boundaries demonstrates the course's consistency. Each of the following lessons will build
new data specific to its own subject and do its own measurement; this measurement only
proves that all of them sit within **the same frame**.

**ND18.** Reading this distribution: **more than half** of the thirteen crossings (seven of
them) lose type information entirely, five are rebuilt with a schema held by the other side,
and only one never loses it at all, thanks to both sides sharing the same schema. This ratio
reflects the standard library's design philosophy: the far side of a boundary is **untyped**
by default, and type only comes back with an explicitly built schema. No crossing carries
type on its own, automatically.

What the five `rebuilt` lines have in common is also instructive: in each one, a different
mechanism rebuilds the type — sharing the same Go type (the first line), a parsing rule
(`time.Parse`), a content type hint, a scanning function (`scan`), a flag parser. The only
thing they share is that all of them are **explicitly written in the other side's code**;
type never appears on its own anywhere, someone has to have written a function that rebuilds
it. This is a pattern that will be encountered again in every lesson of the course:
rebuilding is not a law of nature, it is a line of code.

## Summary

- A struct tag (`json:"name"`) carries the schema for the field mapping between a Go
  field's name and a JSON key's name; if there is no tag, the field name is used as it is.
- A field marked with the `json:"-"` tag never enters the byte sequence; since it never
  crosses the boundary at all, it does not come back either.
- When the same byte sequence is decoded not into a struct but into a container with no
  type schema (`map[string]any`), integer values come back as `float64`.
- A field present on the sending side with no counterpart in the receiving struct drops
  silently; `json.Unmarshal` returns no error in this case.
- The course's full distribution of thirteen boundary crossings was built in this lesson: 1
  preserved, 5 rebuilt, 7 lost; these numbers are a binding reference for the rest of the
  course.
- The single `preserved` line is the crossing where both sides share the same schema; in
  none of the remaining twelve lines do both sides have the same schema.

## Next Step

This lesson measured that a struct tag is a **schema** that carries a field name across, and
that without a schema there is no rebuilding either. The course's binding number was also
built here: type information is lost entirely in seven of the thirteen crossings, rebuilt
with a schema in five, and never lost at all in one — these three numbers will each grow
individually over the next nine lessons. The next topic looks at an exit where no schema
exists at all: a template produces only text from the struct it receives, and no trace
remains in that text of which field came from where — this measurement's sixth line
summarized that in one sentence, the next lesson takes up that same line on its own, in
detail.
