Cherry pick nested JSON fields into a struct in Golang

How to handle non-trivial JSON parsing into Go structs by using a custom Unmarshaler. Or the standard library.

A black cat resting on a wall on the right with one paw reaching down to try to touch a human hand reaching for it from the bottom left. Resembled The Creation of Adam by Michelangelo.

Well hello there! This was a question my brother asked the wider Discord community we’re in not too long ago, and I remember I’ve done this in the past while working at Typeform some years ago.

Suppose you have a struct like this:

type SomeUsefulThing struct {
    Title       string
    Description string
    Content     string
    Created     time.Time
}

And a truly horrendous JSON that looks something like this:

{
  "entry": {
    "id": "something-here",
    "type": "post",
    "tags": [
      "foo",
      "bar",
      "baz"
    ],
    "annotations": {
      "custom-property": "custom-value",
      "other-thing": 52,
      "raw-configuration": "this is a raw thing here\ndo not mind it, you should\nprobably just ignore it\n",
      "description": "an article about existential crisis"
    },
    "metadata": {
      "author": "john doe",
      "author-email": "john@doe.com",
      "created": "2025-08-22T05:31:31+00:00",
      "title": "to whom the bell tolls",
    },
    "content": "some long content here with\nmultiple paragraphs, etc...\n\n\n"
  }
}

As you can see most of it will be discarded, and the fields that we want to parse are all over the place.

The question is, how do we get that JSON into our struct?

The simple way

Create a struct that matches exactly the JSON, and once that was marshaled, extract the fields into your own struct.

type Metadata struct {
	Author      string    `json:"author"`
	AuthorEmail string    `json:"author-email"`
	Created     time.Time `json:"created"`
	Title       string    `json:"title"`
}

type Annotations struct {
	CustomProperty  string `json:"custom-property"`
	OtherThing      int    `json:"other-thing"`
	RawConfiguration string `json:"raw-configuration"`
	Description     string `json:"description"`
}

type Entry struct {
	ID          string      `json:"id"`
	Type        string      `json:"type"`
	Tags        []string    `json:"tags"`
	Annotations Annotations `json:"annotations"`
	Metadata    Metadata    `json:"metadata"`
	Content     string      `json:"content"`
}

type Bridge struct {
	Entry Entry `json:"entry"`
}

type SomeUsefulThing struct {
	Title       string
	Description string
	Content     string
	Created     time.Time
}

func main() {
    var b Bridge
	err := json.Unmarshal(data, &b)
	if err != nil {
		log.Fatalf("json unmarshal: %v", err)
	}

	t := SomeUsefulThing{
		Title:       b.Entry.Metadata.Title,
		Description: b.Entry.Annotations.Description,
		Content:     b.Entry.Content,
		Created:     b.Entry.Metadata.Created,
	}

	fmt.Printf("%+v\n", t)
}
    

This works if your JSON is kinda small, but can get out of hand really really quickly. Some APIs return a lot of data, and you probably don’t want to hand roll multiple intermediary structs just to be able to deal with the fields.

Of course you could also just not define the fields you don’t actually need in the intermediary structs. In that case they would look like this:

type Metadata struct {
	Created time.Time `json:"created"`
	Title   string    `json:"title"`
}

type Annotations struct {
	Description string `json:"description"`
}

type Entry struct {
	Annotations Annotations `json:"annotations"`
	Metadata    Metadata    `json:"metadata"`
	Content     string      `json:"content"`
}

type Bridge struct {
	Entry Entry `json:"entry"`
}

It’s a lot smaller.

The issue still remains if the JSON is deeply nested. Then there’s the other way:

DIY option: writing a custom unmarshaller

When you call json.Unmarshal on some data, the Unmarshal function checks whether the thing you’re trying to shove your data into implements the json.Unmarshaler interface. If it does, then calling json.Unmarshal with your struct as target will use the custom method.

In this case, an unmarshaler and its usage would look like this:

type SomeUsefulThing struct {
	Title       string
	Description string
	Content     string
	Created     time.Time
}

func (s *SomeUsefulThing) UnmarshalJSON(bytes []byte) error {
	if len(bytes) < 2 {
		return errors.New("invalid json, too short")
	}

	if string(bytes) == `""` {
		return errors.New("invalid json, empty")
	}

	var i interface{}

	err := json.Unmarshal(bytes, &i)
	if err != nil {
		return fmt.Errorf("invalid json, could not unmarshal into interface: %w", err)
	}

	// check that the root is an object and not an array
	root, ok := i.(map[string]interface{})
	if !ok {
		return errors.New("invalid json, root is not an object")
	}

	// check whether the incoming json has key entry at the root
	entry, ok := root["entry"].(map[string]interface{})
	if !ok {
		return errors.New("root entry does not exist, or is not an object")
	}

	// check for content, as being the easiest
	content, ok := entry["content"].(string)
	if !ok {
		return errors.New("root content does not exist, or is not a string")
	}

	// check for annotations
	annotations, ok := entry["annotations"].(map[string]interface{})
	if !ok {
		return errors.New("entry annotations does not exist, or is not an object")
	}

	description, ok := annotations["description"].(string)
	if !ok {
		return errors.New("entry description does not exist, or is not a string")
	}

	// check for metadata
	metadata, ok := entry["metadata"].(map[string]interface{})
	if !ok {
		return errors.New("entry metadata does not exist, or is not an object")
	}

	title, ok := metadata["title"].(string)
	if !ok {
		return errors.New("entry metadata title does not exist, or is not a string")
	}

	created, ok := metadata["created"].(string)
	if !ok {
		return errors.New("entry metadata created does not exist, or is not a string")
	}

	createdTime, err := time.Parse(time.RFC3339, created)
	if err != nil {
		return fmt.Errorf("created time is not RFC3339 format: %w", err)
	}
	
	s.Content = content
	s.Title = title
	s.Description = description
	s.Created = createdTime

	return nil
}


func main() {
	var s SomeUsefulThing
	err := json.Unmarshal([]byte(data), &s)
	if err != nil {
		log.Fatalf("json unmarshal: %v", err)
	}

	fmt.Printf("%+v\n", s)
}

It works, it’s flexible, it’s also tedious. There’s a LOT of code in the unmarshaler. It might just be easier to create the many additional intermediary structs I wrote you probably don’t want to do.

If the structure of the JSON is fixed, and you just need to flatten the data, you could combine the two approaches: instead of using a Bridge struct to unmarshal everything first, and then assign the values to your desired struct in the main flow, you could instead hide all of that into the unmarshaler.

The hybrid approach

At this point I would create a separate file to hold everything that’s needed for your custom struct for code organisation purposes, and that would look like this:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"time"
)

type Metadata struct {
	Created time.Time `json:"created"`
	Title   string    `json:"title"`
}

type Annotations struct {
	Description string `json:"description"`
}

type Entry struct {
	Annotations Annotations `json:"annotations"`
	Metadata    Metadata    `json:"metadata"`
	Content     string      `json:"content"`
}

type Bridge struct {
	Entry Entry `json:"entry"`
}

type SomeUsefulThing struct {
	Title       string
	Description string
	Content     string
	Created     time.Time
}

func (s *SomeUsefulThing) UnmarshalJSON(bytes []byte) error {
	var b Bridge
	err := json.Unmarshal(bytes, &b)
	if err != nil {
		return fmt.Errorf("could not unmarshal into Bridge: %w", err)
	}

	s.Created = b.Entry.Metadata.Created
	s.Title = b.Entry.Metadata.Title
	s.Description = b.Entry.Annotations.Description
	s.Content = b.Entry.Content

	return nil
}

This way in your main.go file all you need to do is this:

package main


const data = `...` // omitted for brevity, but this is the raw JSON payload here

func main() {
	var s SomeUsefulThing
	err := json.Unmarshal([]byte(data), &s)
	if err != nil {
		log.Fatalf("json unmarshal: %v", err)
	}

	fmt.Printf("%+v\n", s)
}

Short, sweet, concise, and we offloaded a lot of the custom parsing into our own unmarshaler, which also offloaded a lot of the work to the standard library’s code.

The other consideration is to use Decoder instead of just straight unmarshalig JSON, in case you have huge files and would rather stream the data in over reading everything into memory before you do anything else.

I like the third option, because I don’t want to write custom code if I can use the core library to do the work for myself, but I also like to encapsulate functionality so it’s neatly in one place all by itself.

Which one you’ll choose will of course depend on what you need to do with the values. I can imagine having to do transformations between incoming JSON data and struct value will need some sort of type checking and custom code.

Photo by Humberto Arellano on Unsplash