Repository: jackc/pgx
Pull request #2216 (2025-01-02 20:51:27)

Timestamp incorrectly adds 'Z' when serializing from JSON to indicate GMT, fixes bug #2215
Fixes https://github.com/jackc/pgx/issues/2215


Patch:
diff --git a/pgtype/timestamp.go b/pgtype/timestamp.go
index ff2739d6..c31f2ac5 100644
--- a/pgtype/timestamp.go
+++ b/pgtype/timestamp.go
@@ -12,6 +12,7 @@ import (
 )
 
 const pgTimestampFormat = "2006-01-02 15:04:05.999999999"
+const jsonISO8601 = "2006-01-02T15:04:05.999999999"
 
 type TimestampScanner interface {
 	ScanTimestamp(v Timestamp) error
@@ -76,7 +77,7 @@ func (ts Timestamp) MarshalJSON() ([]byte, error) {
 
 	switch ts.InfinityModifier {
 	case Finite:
-		s = ts.Time.Format(time.RFC3339Nano)
+		s = ts.Time.Format(jsonISO8601)
 	case Infinity:
 		s = "infinity"
 	case NegativeInfinity:
@@ -104,15 +105,23 @@ func (ts *Timestamp) UnmarshalJSON(b []byte) error {
 	case "-infinity":
 		*ts = Timestamp{Valid: true, InfinityModifier: -Infinity}
 	default:
-		// PostgreSQL uses ISO 8601 wihout timezone for to_json function and casting from a string to timestampt
-		tim, err := time.Parse(time.RFC3339Nano, *s+"Z")
-		if err != nil {
-			return err
+		// Parse time with or without timezonr
+		tss := *s
+		//		PostgreSQL uses ISO 8601 without timezone for to_json function and casting from a string to timestampt
+		tim, err := time.Parse(time.RFC3339Nano, tss)
+		if err == nil {
+			*ts = Timestamp{Time: tim, Valid: true}
+			return nil
 		}
-
-		*ts = Timestamp{Time: tim, Valid: true}
+		tim, err = time.ParseInLocation(jsonISO8601, tss, time.UTC)
+		if err == nil {
+			*ts = Timestamp{Time: tim, Valid: true}
+			return nil
+		}
+		ts.Valid = false
+		return fmt.Errorf("cannot unmarshal %s to timestamp with layout %s or %s (%w)",
+			*s, time.RFC3339Nano, jsonISO8601, err)
 	}
-
 	return nil
 }
 


Test patch:
diff --git a/pgtype/timestamp_test.go b/pgtype/timestamp_test.go
index 345da819..bd8c7132 100644
--- a/pgtype/timestamp_test.go
+++ b/pgtype/timestamp_test.go
@@ -2,12 +2,14 @@ package pgtype_test
 
 import (
 	"context"
+	"encoding/json"
 	"testing"
 	"time"
 
 	pgx "github.com/jackc/pgx/v5"
 	"github.com/jackc/pgx/v5/pgtype"
 	"github.com/jackc/pgx/v5/pgxtest"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -100,13 +102,24 @@ func TestTimestampCodecDecodeTextInvalid(t *testing.T) {
 }
 
 func TestTimestampMarshalJSON(t *testing.T) {
+
+	tsStruct := struct {
+		TS pgtype.Timestamp `json:"ts"`
+	}{}
+
+	tm := time.Date(2012, 3, 29, 10, 5, 45, 0, time.UTC)
+	tsString := "\"" + tm.Format("2006-01-02T15:04:05") + "\"" //  `"2012-03-29T10:05:45"`
+	var pgt pgtype.Timestamp
+	_ = pgt.Scan(tm)
+
 	successfulTests := []struct {
 		source pgtype.Timestamp
 		result string
 	}{
 		{source: pgtype.Timestamp{}, result: "null"},
-		{source: pgtype.Timestamp{Time: time.Date(2012, 3, 29, 10, 5, 45, 0, time.UTC), Valid: true}, result: "\"2012-03-29T10:05:45Z\""},
-		{source: pgtype.Timestamp{Time: time.Date(2012, 3, 29, 10, 5, 45, 555*1000*1000, time.UTC), Valid: true}, result: "\"2012-03-29T10:05:45.555Z\""},
+		{source: pgtype.Timestamp{Time: tm, Valid: true}, result: tsString},
+		{source: pgt, result: tsString},
+		{source: pgtype.Timestamp{Time: tm.Add(time.Second * 555 / 1000), Valid: true}, result: `"2012-03-29T10:05:45.555"`},
 		{source: pgtype.Timestamp{InfinityModifier: pgtype.Infinity, Valid: true}, result: "\"infinity\""},
 		{source: pgtype.Timestamp{InfinityModifier: pgtype.NegativeInfinity, Valid: true}, result: "\"-infinity\""},
 	}
@@ -116,12 +129,32 @@ func TestTimestampMarshalJSON(t *testing.T) {
 			t.Errorf("%d: %v", i, err)
 		}
 
-		if string(r) != tt.result {
+		if !assert.Equal(t, tt.result, string(r)) {
 			t.Errorf("%d: expected %v to convert to %v, but it was %v", i, tt.source, tt.result, string(r))
 		}
+		tsStruct.TS = tt.source
+		b, err := json.Marshal(tsStruct)
+		assert.NoErrorf(t, err, "failed to marshal %v %s", tt.source, err)
+		t2 := tsStruct
+		t2.TS = pgtype.Timestamp{} // Clear out the value so that we can compare after unmarshalling
+		err = json.Unmarshal(b, &t2)
+		assert.NoErrorf(t, err, "failed to unmarshal %v with %s", tt.source, err)
+		assert.True(t, tsStruct.TS.Time.Unix() == t2.TS.Time.Unix())
 	}
 }
 
+func TestTimestampUnmarshalJSONErrors(t *testing.T) {
+	tsStruct := struct {
+		TS pgtype.Timestamp `json:"ts"`
+	}{}
+	goodJson1 := []byte(`{"ts":"2012-03-29T10:05:45"}`)
+	assert.NoError(t, json.Unmarshal(goodJson1, &tsStruct))
+	goodJson2 := []byte(`{"ts":"2012-03-29T10:05:45Z"}`)
+	assert.NoError(t, json.Unmarshal(goodJson2, &tsStruct))
+	badJson := []byte(`{"ts":"2012-03-29"}`)
+	assert.Error(t, json.Unmarshal(badJson, &tsStruct))
+}
+
 func TestTimestampUnmarshalJSON(t *testing.T) {
 	successfulTests := []struct {
 		source string