Go version

go version go1.22.4 darwin/amd64

Output of go env in your module/workspace:

GO111MODULE=''
GOARCH='amd64'
GOBIN='/path/to/repo/directory/.hermit/go/bin'
GOCACHE='/Users/USERNAME/Library/Caches/go-build'
GOENV='/Users/USERNAME/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFLAGS=''
GOHOSTARCH='amd64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMODCACHE='/Users/USERNAME/go/pkg/mod'
GONOPROXY='github.com/USERNAME'
GONOSUMDB='github.com/USERNAME'
GOOS='darwin'
GOPATH='/Users/USERNAME/go'
GOPRIVATE='github.com/enosi'
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/Users/USERNAME/Library/Caches/hermit/pkg/go@1.22'
GOSUMDB='sum.golang.org'
GOTMPDIR=''
GOTOOLCHAIN='local'
GOTOOLDIR='/Users/USERNAME/Library/Caches/hermit/pkg/go@1.22/pkg/tool/darwin_amd64'
GOVCS=''
GOVERSION='go1.22.4'
GCCGO='gccgo'
GOAMD64='v1'
AR='ar'
CC='clang'
CXX='clang++'
CGO_ENABLED='1'
GOMOD='/path/to/repo/directory/go.mod'
GOWORK=''
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
PKG_CONFIG='pkg-config'
GOGCCFLAGS='-fPIC -arch x86_64 -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/9q/v1dfygx14czghp71cfmb68r00000gn/T/go-build1533160705=/tmp/go-build -gno-record-gcc-switches -fno-common'

What did you do?

Ref: https://go.dev/play/p/QLy4ID0CwiU

// You can edit this code!
// Click here and start typing.
package main

import (
    "fmt"
    "time"
)

func main() {
    tz, err := time.LoadLocation("Europe/London")
    if err != nil {
        panic(err)
    }

    tsDST := time.Date(2024, 6, 1, 0, 0, 0, 0, tz)
    tsST := time.Date(2024, 12, 1, 0, 0, 0, 0, tz)

    fmt.Println(tsDST.Format(time.RFC3339))
    fmt.Println(tsST.Format(time.RFC3339))
}

What did you see happen?

2024-06-01T00:00:00+01:00
2024-12-01T00:00:00Z

This is due to the following portion of code:

https://github.com/golang/go/blob/b3b4556c245c8f21872910ee866133428bbb5a60/src/time/format_rfc3339.go#L44-L46

Now this is understandable for timezone like time.UTC, however as seen above, we've loaded a timezone that has variable.

What did you expect to see?

It would be great to see the following output for consistency:

2024-06-01T00:00:00+01:00
2024-12-01T00:00:00+00:00

However that may have breaking changes in the codebase.

An alternative would be to explicitly document time.RFC3339 to indicate that any timezone where the offset for a particular time is zero will be returned as Z not as +00:00 regardless of how the timezone may treat other points in time.

In effect, in the absence of being able to resolve a principle of least surprise issue, it would be useful to document the early exit of the time#appendFormatRFC3339 method with a Z instead of an [+-]HH:MM offset where the offset value is 0.

Alternatively, a breaking change would be to validate if the time's location is time.UTC, and use Z, otherwise use +00:00.

Comment From: gabyhelp

Similar Issues

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: seankhliao

RFC 3339 Section 2: https://www.rfc-editor.org/rfc/rfc3339#section-2

Z A suffix which, when applied to a time, denotes a UTC offset of 00:00; often spoken "Zulu" from the ICAO phonetic alphabet > representation of the letter "Z".

Z means zero offset, not UTC itself.

Comment From: jufemaiz

RFC3339 paraphrases the source material – ISO8601.

Golang time: time#Format using time.RFC3339 using time.Time with time.Location Europe/London should use +00:00 instead of Z Ref: ISO 8601-1:2019(en)

Comment From: cooler-SAI

Ok, guys, checked all month, here we go:


package main

import (
    "fmt"
    "time"
)

func main() {
    tz, err := time.LoadLocation("Europe/London")
    if err != nil {
        panic(err)
    }

    dates := []time.Time{
        time.Date(2024, 1, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 2, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 3, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 4, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 5, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 6, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 7, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 8, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 9, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 10, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 11, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 12, 1, 0, 0, 0, 0, tz),
    }

    for _, date := range dates {
        fmt.Println(date.Format(time.RFC3339))
    }
}

Result :

2024-01-01T00:00:00Z
2024-02-01T00:00:00Z
2024-03-01T00:00:00Z
2024-04-01T00:00:00+01:00
2024-05-01T00:00:00+01:00
2024-06-01T00:00:00+01:00
2024-07-01T00:00:00+01:00
2024-08-01T00:00:00+01:00
2024-09-01T00:00:00+01:00
2024-10-01T00:00:00+01:00
2024-11-01T00:00:00Z
2024-12-01T00:00:00Z

Hacked Fixed:

package main

import (
    "fmt"
    "time"
)

func main() {
    tz, err := time.LoadLocation("Europe/London")
    if err != nil {
        panic(err)
    }
    dates := []time.Time{
        time.Date(2024, 1, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 2, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 3, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 4, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 5, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 6, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 7, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 8, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 9, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 10, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 11, 1, 0, 0, 0, 0, tz),
        time.Date(2024, 12, 1, 0, 0, 0, 0, tz),
    }

    for _, date := range dates {
        fmt.Println(date.Format("2006-01-02T15:04:05-07:00"))
    }
}

Result 👍 , but not good

2024-01-01T00:00:00+00:00
2024-02-01T00:00:00+00:00
2024-03-01T00:00:00+00:00
2024-04-01T00:00:00+01:00
2024-05-01T00:00:00+01:00
2024-06-01T00:00:00+01:00
2024-07-01T00:00:00+01:00
2024-08-01T00:00:00+01:00
2024-09-01T00:00:00+01:00
2024-10-01T00:00:00+01:00
2024-11-01T00:00:00+00:00
2024-12-01T00:00:00+00:00

strange is : not all months have this issue

RFC 3339 Section 2: https://www.rfc-editor.org/rfc/rfc3339#section-2

Z A suffix which, when applied to a time, denotes a UTC offset of 00:00; often spoken "Zulu" from the ICAO phonetic alphabet > representation of the letter "Z".

Z means zero offset, not UTC itself.

yeah, it's +00:00

Comment From: ianlancetaylor

I don't think it's feasible for us to change this now. If you don't like the Z, use a format string "2006-01-02T15:04:05-07:00" instead of time.RFC3339.

Comment From: jufemaiz

@ianlancetaylor to be clear, as I've mentioned:

An alternative would be to explicitly document time.RFC3339 to indicate that any timezone where the offset for a particular time is zero will be returned as Z not as +00:00 regardless of how the timezone may treat other points in time.

I'm disappointed that it appears the Golang team have failed to take into account the considerations indicated, or a wider discussion on the implicit use of "Z" and early return in the codebase if offset is zero, as opposed to being an explicit UTC timezone. As noted I've been explicit in one option being better documentation that any offset of zero IS ASSUMED TO BE UTC regardless of the timezone location being used.

Further, discussion points above indicate an assumption that "Z means zero offset, not UTC itself", yet the documentary evidence does not IMHO support this assertion (which is then implicit in the codebase).

Finally, RFC3339 is currently in review for an update, with further implicit and explicit examples highlighting my comments in this issue.

Ref: https://www.ietf.org/archive/id/draft-ietf-sedate-datetime-extended-11.html#name-optional-generation-electiv

Comment From: jufemaiz

Also, the change in the title by @seankhliao was unwarranted and completely at odds with the actual issue. I'm not happy that was made without a note of the change of scope, or a discussion over the topic.

Comment From: ianlancetaylor

Changing the current behavior is sure to break existing working code. We aren't going to do that.

We are always happy to improve the documentation. In this case I'm concerned that the documentation is already lengthy and complex and that adding more information won't actually help people. But I would be happy to look at a patch.

Comment From: jufemaiz

I've drafted a change which I know has no hope of going through, however, is indicative of what I understand of IS8601 (see https://github.com/golang/go/blob/master/src/time/format.go#L787-L817 where RFC3339 is broken down into ISO8601 parts, and, as I've noted, IMHO, incorrectly assumes a corollary of "UTC is offset of zero" to be "zero offset is UTC" even where location information is available).

https://github.com/ace-teknologi/go/blob/d9b5ea30ac12e78c65ae0e55c78f7cf88ff6a434/src/time/format.go#L787-L817 https://github.com/ace-teknologi/go/blob/d9b5ea30ac12e78c65ae0e55c78f7cf88ff6a434/src/time/format_rfc3339.go#L44-L46 https://github.com/ace-teknologi/go/blob/d9b5ea30ac12e78c65ae0e55c78f7cf88ff6a434/src/time/format.go#L666-L672

Comment From: jufemaiz

Changing the current behavior is sure to break existing working code. We aren't going to do that.

We are always happy to improve the documentation. In this case I'm concerned that the documentation is already lengthy and complex and that adding more information won't actually help people. But I would be happy to look at a patch.

Lengthy documentation in the time package is probably par for the course given the innate complexity of it, and its fundamental role in the language IMHO.

Happy to do so if that's the proposed path forward from the Golang team, but would thus prefer the issue to be reopened as a reference point for those changes.

Further, the proposed changes to be made (immediate comment above) should IMHO be considered as part of a Golang v2 release where breaking changes would be allowed, given a fundamental assumption is not necessarily in keeping with the specs it is built on (that is, a location that is not nil or time.UTC but coincidently has an offset of +00:00 is not UTC and should not use the UTC designator).