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#
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
}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 pointerAge is absent because of omitempty, which drops zero values. password is absent because it is lowercase.
Struct tag options#
`json:"name"` // rename
`json:"name,omitempty"` // omit if zero value
`json:"-"` // never include
`json:",string"` // encode a number as a JSON stringjson:"-" is how you keep a secret out of a response while leaving the field exported for your own code:
type User struct {
Email string `json:"email"`
PasswordHash string `json:"-"` // exported, but never serialised
}Decoding unknown shapes#
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:
n := raw["count"].(float64) // not intPrefer 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:
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:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // cap the request size
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject unexpected fieldsWithout MaxBytesReader a client decides how much memory you allocate.
Files#
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:
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:
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:
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:
func countLines(r io.Reader) (int, error) {
n := 0
s := bufio.NewScanner(r)
for s.Scan() { n++ }
return n, s.Err()
}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:
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 bothExercise#
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.
AGENTS.md now — no email needed.