With the release of Go 1.27, the updated json/v2 became part of the standard library. No more experimental flags needed. I was excited for the 1.27 release and already had a use case for json/v2 lined up: A struct that needed time.Duration to be marshaled to seconds instead of nanoseconds. The initial json/v2 experiments made it seems like this should be breeze, as you only needed to specify format:sec to do this. In fact, the discussion about how to represent time.Duration in json/v2 still mentions the format tag as the solution for explicitly specifying how to marshal duration values.
encoding/json/v2.Marshal and encoding/json/v2.Unmarshal will return an error when provided with a time.Duration that does not have a format (such as format:sec or format:iso8601) specified.
Unfortunately, the Go 1.27 release notes make it clear that the format tag option was not carried over from the json/v2 experiment. In contrast, the error returned when trying to Marshal or Unmarshal any time.Duration did make it into the new standard library. This code for example will produce an error in Go 1.27.
package main
import (
"encoding/json/v2"
"fmt"
"time"
)
func main() {
s := struct {
Duration time.Duration `json:"duration"`
}{
Duration: 5 * time.Second,
}
bytes, err := json.Marshal(s)
if err != nil {
panic(err) // json: unable to marshal from Go time.Duration within "/duration": no default representation
}
fmt.Println(string(bytes))
}
The error does state that json.Marshal does not know how to represent time.Duration in JSON, but there’s no instructions for how to address the issue. Even the official guide for migrating to json/v2 mentions neither time.Duration nor the “no default representation” error. The guide does, however, link to a migration section in the documentation for the encoding/json package, which finally reveals how to resolve the error.
In v1, a time.Duration is represented as a JSON number containing the decimal number of nanoseconds. In contrast, in v2 a time.Duration has no default representation and results in a runtime error. The FormatDurationAsNano option controls this behavior difference.
To make the example work, we need to pass the option when marshaling, telling json/v2 to format the duration as nanoseconds.
package main
import (
jsonv1 "encoding/json"
"encoding/json/v2"
"fmt"
"time"
)
func main() {
s := struct {
Duration time.Duration `json:"duration"`
}{
Duration: 5 * time.Second,
}
bytes, err := json.Marshal(s, jsonv1.FormatDurationAsNano(true))
if err != nil {
panic(err)
}
fmt.Println(string(bytes)) // {"duration":5000000000}
}
Having to always specifically pass the option when using Marshal or Unmarshal seems quite cumbersome to me personally. For tailscale, dsnet used a custom wrapper type DurationNano instead, though that does not necessarily appear easier to use in practice.
There is hope in the form of a new proposal which would bring typed struct tags to json/v2, allowing us to specify the format after all. We will have to wait and see whether the proposal will be accepted and make its way into one of the next Go releases.