How to change the JSON time format

Avatar of the author Willem Schots
25 Jan, 2024
~12 min.
RSS

The JSON standard does not have a built in type for time, so time values are usually handled as strings. With strings, there’s always a choice that must be made: How to format these the time values?

In the time package the choice was made to format time.Time values as time.RFC3339Nano by default. A common and practical format:

{
  "timestamp": "2024-01-24T00:00:00Z"
}

But what if you want to use a different format? Say YYYY-DD-MM, 25 Jan 2024 or Unix timestamps?

Well… that’s what we’ll discuss in this article.

We’ll look at both formatting to JSON, and parsing from JSON.

JSON Marshaling/Unmarshaling

In Go the encoding/json package is responsible for transforming between Go values and JSON. This transforming is referred to as “Marshaling” (to JSON) and “Unmarshaling” (from JSON).

The json package has default transformation rules for most Go data types. These rules can be modified or overwritten in several ways:

  • Specifying struct tags: Change (some) rules on a field-by-field basis. Skipping or renaming specific fields for example.
  • Encoder settings: Change indentation and HTML escaping rules.
  • Implementing the json.Marshaler and/or json.Unmarshaler interfaces: Create custom transformation logic per Go type.

This last method is what we’ll use in this article.

In the first part of this walkthrough we’ll implement the interfaces on a custom type and transform between Go strings and JSON strings ourselves. Later, we’ll let Go take care of this transformation and fix a possible downside of this implementation.

With that said, let’s get started.

The situation

For our example we’ll assume we should interpret all data as being in the UTC location.

Let’s say we want to format our time data in a format like this: 25 Jan 2024 11:24AM.

Our custom type looks as follows.

type Datetime time.Time

Here we define a new Datetime type that is structurally identical to time.Time. Note that Datetime is a distinct type, the methods of time.Time are not available on Datetime.

Implement json.Marshaler

Go to JSON
json.Marshaler converts from Go to JSON.

Let’s deal with the Go to JSON transformation first. For this transformation we need to implement the json.Marshaler interface:

// Marshaler is the interface implemented by types
// that can marshal themselves into valid JSON. 
type Marshaler interface {
	MarshalJSON() ([]byte, error)
}

If we add this to our custom Datetime type we get the following code:

package main

type Datetime time.Time

func (d Datetime) MarshalJSON() ([]byte, error) {
	// TODO: Implement.
}

But what do we put inside this method?

Let’s work backwards from the desired result.

We want each Datetime to be represented by a JSON string when transformed to JSON. This JSON string needs to be returned as a byte slice ([]byte).

JSON strings are sequences of zero or more unicode characters wrapped in double quotes (").

Inside this JSON string we want our time to be formatted in the format we saw earlier. For example, results could look like "25 Jan 2024 11:24AM" or "1 Dec 2024 8:04PM".

This gives us the recipe for the implementation:

  1. Format the time to a Go string in our desired format.
  2. Wrap this Go string with double quotes to get a JSON string.

Let’s implement the first step.

// ...

func (d Datetime) MarshalJSON() ([]byte, error) {
	// Step 1. Format the time as a Go string.
	t := time.Time(d)
	formatted := t.Format("2 Jan 2006 3:04PM")

	// ...
}

Our Datetime type has no methods for formatting. However, since it’s structurally identical to time.Time we can use a type conversion to convert it to a time.Time type. Which does have such a method.

The Format method uses a reference layout to format the resulting string. "2 Jan 2006 3:04PM" in this case. You can learn more about reference layouts articles in my article on parsing times and dates.

Now that we have our time as a formatted Go string, we can convert it to a JSON string:

// ...

func (d Datetime) MarshalJSON() ([]byte, error) {
	// Step 1. Format the time as a Go string.
	t := time.Time(d)
	formatted := t.Format("2 Jan 2006 3:04PM")

	// Step 2. Convert our formatted time to a JSON string.
	jsonStr := "\"" + formatted + "\""
	return []byte(jsonStr), nil
}

First we wrap formatted inside two double quotes, which we need to escape using \ because they’re special characters in Go strings.

The MarshalJSON method expects us to return a byte slice ([]byte). In the return statement we use another type conversion to convert jsonStr from string to []byte.

Putting it all together gives us the following code. If you run the code, you will see that our Datetime type will now be marshalled to JSON.

main.go
package main

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

type Datetime time.Time

func (d Datetime) MarshalJSON() ([]byte, error) {
	// Step 1. Format the time as a Go string.
	t := time.Time(d)
	formatted := t.Format("2 Jan 2006 3:04PM")

	// Step 2. Convert our formatted time to a JSON string.
	jsonStr := "\"" + formatted + "\""
	return []byte(jsonStr), nil
}

func main() {
	// Create a datetime by converting from a time.Time
	d := Datetime(time.Date(2024, 01, 25, 11, 24, 0, 0, time.UTC))

	// Marshal the datetime as JSON.
	result, err := json.Marshal(d)
	if err != nil {
		log.Fatal(err)
	}

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

Change the type of d to time.Time to see the difference in output.

Note that when you remove the UnmarshalJSON method, the Datetime type will be marshalled into an empty JSON object. There is no falling back to the time.Time implementation.

Again, this is because time.Time and Datetime are distinct types.

Implement json.Unmarshaler

JSON to Go
json.Unmarshaler converts from JSON to Go.

Now that we can transform from Go to JSON, let’s implement the JSON to Go transformation as well.

For this transformation we will need to implement the json.Unmarshaler interface on our custom type:

// Unmarshaler is the interface implemented by types
// that can unmarshal a JSON description of themselves.
type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

When we add this method to our type it looks like this:

// ...

func (d *Datetime) UnmarshalJSON(b []byte) error {
	// TODO: Implement.
}

Please note that we implement this method using a pointer receiver. This is because we want to modify an existing date using this method. If the method had a value receiver this would not be possible.

So, how do we implement this method?

Well.. this needs to do the inverse of MarshalJSON which we just implemented.

  1. Strip the double quotes from the JSON string.
  2. Parse the resulting Go string using our desired format.
// ...

func (d *Datetime) UnmarshalJSON(b []byte) error {
	if len(b) < 2 || b[0] != '"' || b[len(b)-1] != '"' {
		return errors.New("not a json string")
	}
	
	// 1. Strip the double quotes from the JSON string.
	b = b[1:len(b)-1]

	// ...
}

As you can see, we first verify that the byte slice b contains what we expect: A JSON string.

We verify this by:

  • Check if there are at least 2 elements (one for each double quote).
  • Check if the first and last element in b are double quotes.

If this verification fails we stop unmarshaling and return an error.

In case we’re dealing with a JSON string we strip the first and last element of the byte slice by reslicing b.

If you're unsure how reslicing works, check out my articles on slices.

If b initially contained "25 Jan 2024" it will now contain 25 Jan 2024.

We’re now ready for the second step, parsing b using our desired format.

// ...

func (d *Datetime) UnmarshalJSON(b []byte) error {
	if len(b) < 2 || b[0] != '"' || b[len(b)-1] != '"' {
		return errors.New("not a json string")
	}
	
	// 1. Strip the double quotes from the JSON string.
	b = b[1:len(b)-1]

	// 2. Parse the result using our desired format.
	t, err := time.Parse("2 Jan 2006 3:04PM", string(b))
	if err != nil {
		return fmt.Errorf("failed to parse time: %w", err)
	}

	// finally, assign t to *d
	*d = Datetime(t)

	return nil
}

Just like Format we saw earlier, time.Parse works with a reference layout.

The result of time.Parse is a time.Time value, we need to convert it a Datetime before we can assign it to *d.

This wraps up the UnmarshalJSON method. You can play with it in the example below.

main.go
package main

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

type Datetime time.Time

func (d *Datetime) UnmarshalJSON(b []byte) error {
	if len(b) < 2 || b[0] != '"' || b[len(b)-1] != '"' {
		return errors.New("not a json string")
	}
	
	// 1. Strip the double quotes from the JSON string.
	b = b[1:len(b)-1]

	// 2. Parse the result using our desired format.
	t, err := time.Parse("2 Jan 2006 3:04PM", string(b))
	if err != nil {
		return fmt.Errorf("failed to parse time: %w", err)
	}

	// finally, assign t to *d
	*d = Datetime(t)

	return nil
}

func main() {
  jsonStr := "\"25 Jan 2024 11:24AM\""
  var d Datetime
  err := json.Unmarshal([]byte(jsonStr), &d)
  if err != nil {
		log.Fatal(err)
  }

  // Need to convert to a time.Time to print d nicely.
  fmt.Println(time.Time(d))
}

Skip the manual JSON wrangling

In the above implementations, we build the functionality to convert between JSON strings and Go strings ourselves.

In our situation this was fairly straightforward and nicely emphasized what is actually going on in these methods.

However, when dealing with more complex situations it’s often a good idea to leverage existing functionality to minimize the work required (and unnecessary duplicate code).

The json.Marshal and json.Unmarshal functions already know how to deal with most types. If we apply them to our earlier implementation we can skip all of the wrangling with double quotes.

See the annotated example below.

main.go
package main

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

type Datetime time.Time

func (d Datetime) MarshalJSON() ([]byte, error) {
	// Step 1. Format the time as a Go string.
	t := time.Time(d)
	formatted := t.Format("2 Jan 2006 3:04PM")

	// Step 2. Marshal formatted to a JSON string. json.Marshal identifies
	// formatted as a Go string and then outputs it as a JSON string.
	return json.Marshal(formatted)
}

func (d *Datetime) UnmarshalJSON(b []byte) error {
	// 1. Unmarshal b to a Go string. json.Unmarshal uses reflection
	// to identify s as a string and then interprets b as JSON string.
	var s string
	err := json.Unmarshal(b, &s)
	if err != nil {
		return fmt.Errorf("failed to unmarshal to a string: %w", err)
	}

	// 2. Parse the result using our desired format.
	t, err := time.Parse("2 Jan 2006 3:04PM", s)
	if err != nil {
		return fmt.Errorf("failed to parse time: %w", err)
	}

	// finally, assign t to *d
	*d = Datetime(t)

	return nil
}

func main() {
	// Below we create a Datetime, transform it to JSON and transform
	// it back to Go.  

	// Create a datetime by converting from a time.Time
	in := Datetime(time.Date(2024, 01, 25, 0, 0, 0, 0, time.UTC))

	b, err := json.Marshal(in)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s\n", b)

	var out Datetime
	err = json.Unmarshal(b, &out)
	if err != nil {
		log.Fatal(err)
	}

	// Again, convert to time.Time for nicer printing.
	fmt.Printf("%s\n", time.Time(out))
}

Minimizing type conversions

As you have seen, implementing the marshalling methods on a custom type works. But it requires a type conversion everywhere you want to interpret it as a time.Time value.

Depending on your code, this can get pretty tedious and make code less readable.

One solution is to implement the methods on a wrapping struct type instead. Consumers will have to use a field, but won’t require a type conversion.

In the annotated example below, the methods are defined on a Datetime struct.

main.go
package main

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

type Datetime struct {
	T time.Time
}

func (r Datetime) MarshalJSON() ([]byte, error) {
	// Step 1. Format the datetime as a Go string (no type conversion required).
	formatted := r.T.Format("2 Jan 2006 3:04PM")

	// Step 2. Marshal formatted to a JSON string. json.Marshal identifies
	// formatted as a Go string and then outputs it as a JSON string.
	return json.Marshal(formatted)
}

func (d *Datetime) UnmarshalJSON(b []byte) error {
	// 1. Unmarshal b to a Go string. json.Unmarshal uses reflection
	// to identify s as a string and then interprets b as JSON string.
	var s string
	err := json.Unmarshal(b, &s)
	if err != nil {
		return fmt.Errorf("failed to unmarshal to a string: %w", err)
	}

	// 2. Parse the result using our desired format.
	t, err := time.Parse("2 Jan 2006 3:04PM", s)
	if err != nil {
		return fmt.Errorf("failed to parse time: %w", err)
	}

	// finally, assign the time value
	d.T = t

	return nil
}

func main() {
	// Below we create a Datetime, transform it to JSON and transform
	// it back to Go.

	// Create a datetime by converting from a time.Time
	in := Datetime{
		T: time.Date(2024, 01, 25, 11, 24, 0, 0, time.UTC),
	}

	b, err := json.Marshal(in)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s\n", b)

	var out Datetime
	err = json.Unmarshal(b, &out)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s\n", out.T)
}

Whether this is a worthwhile change depends on your code and situation.

Another alternative is to implement the methods directly on request and response times, but this can also get a bit involved when masking or overwriting fields. As this article is already elaborate enough, let’s end it here :)

Summary

This article discussed changing the time format used by the json package.

We discussed how to implement the json.MarshalJSON and json.UnmarshalJSON interfaces in two ways:

  • By dealing with the JSON format directly.
  • By leveraging json.Marshal and json.Unmarshal.

We also looked at the types we defined these methods on.

  • Using a custom time.Time type will likely lead to many type conversions.
  • Using a wrapping struct will require you to access the time.Time via a field, but won’t require any type conversions.

I hope you learned something and that this article will help you make the right trade-offs in your code.

Happy coding!

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Mastodon, Twitter/X or LinkedIn.

Thanks for reading!