Foundations Updated 2026-09 View as Markdown

JSON and File I/O

Struct tags, the reader and writer interfaces that unify all I/O in Go, and the capitalisation mistake that silently drops your fields.

JSON#

go
type User struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Age       int       `json:"age,omitempty"`
    CreatedAt time.Time `json:"created_at"`
    password  string    // unexported — never serialised
}
go
u := User{ID: "1", Email: "ada@example.com", CreatedAt: time.Now()}

data, err := json.Marshal(u)
// {"id":"1","email":"ada@example.com","created_at":"2026-09-05T..."}

var back User
err = json.Unmarshal(data, &back)     // note the & — it must be a pointer

Age is absent because of omitempty, which drops zero values. password is absent because it is lowercase.

Struct tag options#

go
`json:"name"`             // rename
`json:"name,omitempty"`   // omit if zero value
`json:"-"`                // never include
`json:",string"`          // encode a number as a JSON string

json:"-" is how you keep a secret out of a response while leaving the field exported for your own code:

go
type User struct {
    Email        string `json:"email"`
    PasswordHash string `json:"-"`      // exported, but never serialised
}

Decoding unknown shapes#

go
var raw map[string]any
json.Unmarshal(data, &raw)

if name, ok := raw["name"].(string); ok {
    fmt.Println(name)
}

Numbers decode into float64 by default, which surprises people:

go
n := raw["count"].(float64)     // not int

Prefer a struct whenever you know the shape — it is faster, type-safe and self-documenting. Reach for map[string]any only for genuinely dynamic data.

Streaming#

For anything large, or for HTTP, use the encoder and decoder rather than the whole-buffer functions:

go
func handler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

This avoids loading the entire body into memory. Two production details generated code omits:

go
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // cap the request size
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()                       // reject unexpected fields

Without MaxBytesReader a client decides how much memory you allocate.

Files#

go
data, err := os.ReadFile("config.json")     // whole file into memory
err = os.WriteFile("out.json", data, 0o644)

Simple and correct for small files. For large ones, stream:

go
f, err := os.Open("large.log")
if err != nil {
    return fmt.Errorf("open: %w", err)
}
defer f.Close()

scanner := bufio.NewScanner(f)
for scanner.Scan() {
    line := scanner.Text()
    // ...
}
if err := scanner.Err(); err != nil {     // check this — Scan returns false on error too
    return fmt.Errorf("scan: %w", err)
}

scanner.Err() is easy to forget and is the difference between "finished the file" and "gave up halfway".

Writing with a buffer:

go
f, err := os.Create("out.txt")
if err != nil { return err }
defer f.Close()

w := bufio.NewWriter(f)
defer w.Flush()          // without this, buffered data is lost
for _, line := range lines {
    fmt.Fprintln(w, line)
}

io.Reader and io.Writer#

The two interfaces that unify all I/O in Go, and the best example of why small interfaces work:

go
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }

A file, a network connection, an HTTP body, a compressed stream, an in-memory buffer and standard input are all io.Readers. So a function that takes one works with all of them:

go
func countLines(r io.Reader) (int, error) {
    n := 0
    s := bufio.NewScanner(r)
    for s.Scan() { n++ }
    return n, s.Err()
}
go
countLines(file)
countLines(resp.Body)
countLines(strings.NewReader("a\nb\nc"))     // trivially testable
countLines(os.Stdin)

That last line is the payoff: accept io.Reader rather than a filename and your function becomes testable without touching the filesystem. It is the single most useful API design habit in Go.

Useful helpers:

go
io.Copy(dst, src)                    // stream one into the other
io.ReadAll(r)                        // whole thing into memory
io.LimitReader(r, 1<<20)             // cap it — use on untrusted input
io.MultiWriter(f, os.Stdout)         // write to both

Exercise#

go
package main

import "fmt"

type Product struct {
	// Add tags so this encodes as:
	//   {"sku":"A1","name":"Widget","price_cents":499}
	// with an internal cost field that is NEVER serialised,
	// and a "discount" field omitted when zero.
	SKU  string
	Name string
	PriceCents int
	Discount int
	costCents int
}

func main() {
	// Encode a Product, print it, decode it back, print the result.
	fmt.Println("start")
}

Common questions#

Why is my JSON field always empty?#

Almost certainly because the struct field is lowercase. encoding/json uses reflection and cannot see unexported fields, so they are silently skipped in both directions. Capitalise the field and use a tag for the wire name.

Why did my number become a float?#

Decoding into map[string]any gives every JSON number as float64, since JSON has one numeric type. Decode into a struct with an int field, or use json.Number if you need the exact text.

Should my function take a filename or an io.Reader?#

An io.Reader. It makes the function work with files, network responses, buffers and stdin, and it makes tests trivial — strings.NewReader replaces a fixture file. Open the file in the caller.

Get the Go agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.