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/orjson.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
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
).
"
).
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:
- Format the time to a Go string in our desired format.
- 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.
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.
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.
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
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.
- Strip the double quotes from the JSON string.
- 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 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.
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.
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.
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
andjson.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!
Get my free newsletter every second week
Used by 500+ developers to boost their Go skills.
"I'll share tips, interesting links and new content. You'll also get a brief guide to time for developers for free."