Lesson 03 / 12
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.
Published
In this lesson
- 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
Contents
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.
// 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.
// 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 asfloat64. - A field present on the sending side with no counterpart in the receiving struct drops
silently;
json.Unmarshalreturns 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
preservedline 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.