Day 20: JSON and CSV Encoding
Core Concept: Struct Tags Drive Serialisation
Go’s encoding/json uses reflection and struct field tags to map between Go types and JSON automatically.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // omit if empty
Password string `json:"-"` // never serialise
CreatedAt time.Time `json:"created_at"`
}
u := User{ID: 1, Name: "Alice"}
data, err := json.Marshal(u)
// {"id":1,"name":"Alice","created_at":"0001-01-01T00:00:00Z"}
var parsed User
err = json.Unmarshal(data, &parsed)
Streaming JSON (for large payloads)
// Encode to a writer
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(users)
// Decode from a reader
dec := json.NewDecoder(resp.Body)
dec.DisallowUnknownFields() // strict mode
var result Response
err := dec.Decode(&result)
Raw JSON and Dynamic Parsing
// Delay parsing with json.RawMessage
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// Parse into map for unknown structure
var m map[string]any
json.Unmarshal(data, &m)
Custom Marshaling
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil { return err }
dur, err := time.ParseDuration(s)
*d = Duration(dur)
return err
}
encoding/csv
// Reading
r := csv.NewReader(strings.NewReader(data))
r.FieldsPerRecord = -1 // variable column count
records, err := r.ReadAll()
// Streaming
for {
record, err := r.Read()
if err == io.EOF { break }
}
// Writing
w := csv.NewWriter(os.Stdout)
w.Write([]string{"name", "age", "email"})
w.Flush()
Labs
Lab 1: Basic JSON Marshal/Unmarshal
What you’ll practise: Encoding a struct to JSON and decoding it back using json.Marshal and json.Unmarshal.
Task:
Define a Person struct with json tags, marshal it to indented JSON, print it, then unmarshal the bytes back into a new variable and verify the round-trip.
Steps:
- Define
Personwith fieldsName,Age, andEmailwithjson:"..."tags - Marshal a
Personvalue usingjson.MarshalIndent - Print the JSON string
- Unmarshal back into a second
Personand print both values
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
}
p := Person{Name: "Alice", Age: 30, Email: "alice@example.com"}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
var p2 Person
if err := json.Unmarshal(data, &p2); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", p2)
Expected output:
{
"name": "Alice",
"age": 30,
"email": "alice@example.com"
}
{Name:Alice Age:30 Email:alice@example.com}
Checkpoint: Both the original and decoded struct print identical field values.
Lab 2: JSON Struct Tags — omitempty and Field Renaming
What you’ll practise: Controlling JSON output with omitempty, -, and custom field names.
Task:
Define a User struct that demonstrates three tag behaviours: renamed field, omitted-when-empty field, and never-serialised field. Marshal two instances — one with all fields set, one with empty fields — and compare the output.
Steps:
- Define
UserwithID int,Name string,Email string(omitempty), andPassword string(never serialise) - Marshal a fully populated
Userand an empty-emailUser - Observe which fields appear in each output
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Password string `json:"-"`
}
full := User{ID: 1, Name: "Bob", Email: "bob@example.com", Password: "secret"}
empty := User{ID: 2, Name: "Carol"}
for _, u := range []User{full, empty} {
b, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(b))
}
Expected output:
{
"id": 1,
"name": "Bob",
"email": "bob@example.com"
}
{
"id": 2,
"name": "Carol"
}
Checkpoint: Password never appears; email is absent when empty; no other fields are missing.
Lab 3: Streaming JSON with Decoder/Encoder
What you’ll practise: Decoding a JSON array token-by-token from a strings.Reader without loading everything into memory at once.
Task:
Build a function that reads a JSON array of Product objects from a strings.Reader using json.NewDecoder and streams each decoded product to os.Stdout via json.NewEncoder.
Steps:
- Prepare a multi-item JSON array string
- Create a
json.Decoderaround astrings.NewReader - Read the opening
[token, then loop callingdec.Decode(&p)until] - Encode each decoded product to stdout with
json.NewEncoder
const input = `[
{"id":1,"name":"Widget","price":9.99},
{"id":2,"name":"Gadget","price":24.50},
{"id":3,"name":"Doohickey","price":4.75}
]`
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
dec := json.NewDecoder(strings.NewReader(input))
dec.Token() // consume '['
enc := json.NewEncoder(os.Stdout)
for dec.More() {
var p Product
if err := dec.Decode(&p); err != nil {
log.Fatal(err)
}
enc.Encode(p)
}
Expected output:
{"id":1,"name":"Widget","price":9.99}
{"id":2,"name":"Gadget","price":24.5}
{"id":3,"name":"Doohickey","price":4.75}
Checkpoint: Each line is a single JSON object; the program handles the array without calling json.Unmarshal on the whole string.
Lab 4: Custom MarshalJSON / UnmarshalJSON
What you’ll practise: Implementing the json.Marshaler and json.Unmarshaler interfaces to serialise a Duration as a human-readable string.
Task:
Define a Duration type based on time.Duration. Implement MarshalJSON so it emits "1h30m" and UnmarshalJSON so it parses that string back. Embed Duration in a struct and round-trip it through JSON.
Steps:
- Define
type Duration time.Duration - Implement
MarshalJSON() ([]byte, error)usingtime.Duration.String() - Implement
UnmarshalJSON(b []byte) errorusingtime.ParseDuration - Embed in a
Taskstruct and marshal/unmarshal a value
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
dur, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(dur)
return nil
}
type Task struct {
Name string `json:"name"`
Timeout Duration `json:"timeout"`
}
Expected output:
{"name":"build","timeout":"1h30m0s"}
Task: build, Timeout: 1h30m0s
Checkpoint: The JSON shows a string like "1h30m0s", not a raw integer nanosecond count.
Lab 5: CSV Reading into Typed Structs
What you’ll practise: Using encoding/csv to read raw records and map them into typed structs, including handling quoted fields.
Task:
Given a CSV string with a header row, use csv.NewReader to read all records, skip the header, and convert each row into an Employee struct. Handle a field that contains a comma by quoting it in the input.
Steps:
- Write a CSV string with header
name,department,salary; include a row where the department is"Engineering, Core"(quoted) - Create a
csv.NewReaderaround astrings.NewReader - Call
r.Read()to skip the header, then loop withr.Read()for data rows - Parse each row into an
Employeestruct and print all employees
const csvData = `name,department,salary
Alice,"Engineering, Core",95000
Bob,Marketing,72000
Carol,Sales,68000
`
type Employee struct {
Name string
Department string
Salary int
}
r := csv.NewReader(strings.NewReader(csvData))
r.Read() // skip header
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
salary, _ := strconv.Atoi(rec[2])
e := Employee{Name: rec[0], Department: rec[1], Salary: salary}
fmt.Printf("%+v\n", e)
}
Expected output:
{Name:Alice Department:Engineering, Core Salary:95000}
{Name:Bob Department:Marketing Salary:72000}
{Name:Carol Department:Sales Salary:68000}
Checkpoint: The quoted comma-containing department parses correctly as a single field.
Lab 6: CSV Writing with a Header Row
What you’ll practise: Using csv.NewWriter to write a header row and data rows, then calling Flush and checking for errors.
Task:
Take a slice of Product structs and write them to a strings.Builder as CSV with a header row. Print the result and verify it looks correct.
Steps:
- Define a slice of
Product{Name, Price, Stock} - Create a
csv.NewWriteraround astrings.Builder - Write the header row
[]string{"name","price","stock"} - Loop over products, writing each as a string slice
- Call
w.Flush()and checkw.Error()
type Product struct {
Name string
Price float64
Stock int
}
products := []Product{
{"Widget", 9.99, 100},
{"Gadget", 24.50, 45},
{"Doohickey", 4.75, 200},
}
var sb strings.Builder
w := csv.NewWriter(&sb)
w.Write([]string{"name", "price", "stock"})
for _, p := range products {
w.Write([]string{p.Name, strconv.FormatFloat(p.Price, 'f', 2, 64), strconv.Itoa(p.Stock)})
}
w.Flush()
if err := w.Error(); err != nil {
log.Fatal(err)
}
fmt.Print(sb.String())
Expected output:
name,price,stock
Widget,9.99,100
Gadget,24.50,45
Doohickey,4.75,200
Checkpoint: Output matches the expected CSV with the header row first and no trailing comma.
Lab 7: JSON-to-CSV Converter
What you’ll practise: Reading the same data in JSON format and writing it out as CSV — bridging the two encodings.
Task:
Write a function convertJSONToCSV(jsonInput string, w io.Writer) error that decodes a JSON array of Record{Name, Value string} objects and writes them as CSV (with header) to the provided writer.
Steps:
- Define
Record{Name, Value string}with JSON tags - Unmarshal the input JSON array into
[]Record - Create a
csv.NewWriteraround the providedio.Writer - Write header
["name","value"]then one row per record - Flush and return any error
const jsonInput = `[
{"name":"alpha","value":"1"},
{"name":"beta","value":"2"},
{"name":"gamma","value":"3"}
]`
func convertJSONToCSV(jsonInput string, w io.Writer) error {
var records []struct {
Name string `json:"name"`
Value string `json:"value"`
}
if err := json.Unmarshal([]byte(jsonInput), &records); err != nil {
return err
}
cw := csv.NewWriter(w)
cw.Write([]string{"name", "value"})
for _, r := range records {
cw.Write([]string{r.Name, r.Value})
}
cw.Flush()
return cw.Error()
}
Expected output:
name,value
alpha,1
beta,2
gamma,3
Checkpoint: Running convertJSONToCSV(jsonInput, os.Stdout) produces valid CSV with the correct header.
Final Lab: Config Manager
What you’ll practise: Combining JSON encode/decode, custom marshaling, and CSV export in a single program.
Task:
Build a config manager that loads from a JSON file, merges environment variable overrides, saves the merged config back to JSON with indentation, and exports it as CSV. Use a LogLevel type with custom MarshalJSON/UnmarshalJSON that maps between "debug"/"info"/"warn"/"error" strings and integer constants.
Steps:
- Define
Config{Host string, Port int, DatabaseURL string, LogLevel LogLevel, Features map[string]bool} - Implement
LogLevel.MarshalJSON(emit string) andUnmarshalJSON(parse string to int) - Load from
config.jsonwithjson.NewDecoder; mergeos.Getenvoverrides - Save merged config with
json.MarshalIndenttoconfig.out.json - Export all fields as a two-column CSV (
key,value) usingcsv.NewWriter
type LogLevel int
const (
LogDebug LogLevel = iota
LogInfo
LogWarn
LogError
)
func (l LogLevel) MarshalJSON() ([]byte, error) {
names := []string{"debug", "info", "warn", "error"}
if int(l) >= len(names) {
return nil, fmt.Errorf("unknown log level: %d", l)
}
return json.Marshal(names[l])
}
func (l *LogLevel) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
names := map[string]LogLevel{"debug": LogDebug, "info": LogInfo, "warn": LogWarn, "error": LogError}
v, ok := names[s]
if !ok {
return fmt.Errorf("unknown log level: %q", s)
}
*l = v
return nil
}
Expected output:
config loaded: {Host:localhost Port:5432 LogLevel:1}
saved to config.out.json
CSV:
key,value
host,localhost
port,5432
log_level,info
Checkpoint: config.out.json contains human-readable indented JSON with "log_level":"info" (not an integer); the CSV has the correct header and one row per field.
Day Project: Config Manager
Build a config manager that:
- Defines a
Configstruct (server host/port, database URL, log level, feature flags) - Loads config from JSON file with
json.NewDecoder - Merges environment variable overrides (
os.Getenv) - Saves the merged config back to JSON with indentation
- Exports the config as CSV with headers
Use custom MarshalJSON/UnmarshalJSON for a LogLevel type ("debug" ↔ integer).
Extension ideas: support YAML via gopkg.in/yaml.v3; add JSON schema validation.
Official Documentation
encoding/json—Marshal,Unmarshal,NewEncoder,NewDecoder,RawMessage, struct tags (json:"...")encoding/csv—NewReader,NewWriter,Reader.ReadAll,Writer.Flushos—Getenvfor environment variable overridesio—EOF,Readerused with streaming decodersstrings—NewReaderused in CSV examplestime—Time,Durationused in struct fields and custom marshaling- Go Blog: JSON and Go — struct tags, streaming, custom marshaling walkthrough
- Language Spec — Struct tags