Repository: gohugoio/hugo (language: go)

Issue:
tpl/time: Add time.In function
This has come up at least a couple of times:

- <https://discourse.gohugo.io/t/localize-utc-time-with-specific-timezone/54173>
- <https://discourse.gohugo.io/t/present-a-date-object-in-different-timezones/41863>

Example usage (contrived):

```text
{{ $eventDate := time.AsTime "2025-03-30T11:20:00-04:00" }}
{{ $timeZoneNames := slice "America/Denver" "Australia/Adelaide" "Europe/Oslo" }}

<ul>
  {{ range $timeZoneNames }}
    <li>{{ $eventDate | time.In . | time.Format "2 Jan 2006 3:04:05 PM" }} ({{ . }})</li>
  {{ end }}
</ul>
```

Rendered:

![Image](https://github.com/user-attachments/assets/030e0495-cda2-4274-bc53-55192b3d1724)

Proposed function:

```go
// In returns the time t in the IANA time zone specified by timeZoneName.
// If timeZoneName is "" or "UTC", the time is returned in UTC.
// If timeZoneName is "Local", the time is returned in the system's local time zone.
// Otherwise, timeZoneName must be a valid IANA location name (e.g., "Europe/Oslo").
func (ns *Namespace) In(timeZoneName string, t time.Time) (time.Time, error) {
	location, err := time.LoadLocation(timeZoneName)
	if err != nil {
		return time.Time{}, err
	}

	return t.In(location), nil
}
```




Discussion / hints:
Yea, we can add this, but note to implementer is to also add a simple benchmark. Last I checked on this `time.LoadLocation` was slow, but should be highly cachable.

Another implementation detail... I recall having to embed tzdata for Windows, but I don't remember the details or how that would affect this.

---

EDIT: To address #8892 we import the [time/tzdata](https://pkg.go.dev/time/tzdata) package in `commands/hugo_windows.go`.

> If this package is imported anywhere in the program, then if the time package cannot find tzdata files on the system, it will use this embedded information.

So we don't need to do anything for this new function to work on Windows.

Fix patch:
diff --git a/tpl/time/init.go b/tpl/time/init.go
index 5f9dd77bf27..b13d0ab8902 100644
--- a/tpl/time/init.go
+++ b/tpl/time/init.go
@@ -29,7 +29,7 @@ func init() {
 		if d.Conf.Language() == nil {
 			panic("Language must be set")
 		}
-		ctx := New(langs.GetTimeFormatter(d.Conf.Language()), langs.GetLocation(d.Conf.Language()))
+		ctx := New(langs.GetTimeFormatter(d.Conf.Language()), langs.GetLocation(d.Conf.Language()), d)
 
 		ns := &internal.TemplateFuncsNamespace{
 			Name: name,
diff --git a/tpl/time/time.go b/tpl/time/time.go
index 57b115f3557..f7c0db75e3c 100644
--- a/tpl/time/time.go
+++ b/tpl/time/time.go
@@ -18,16 +18,28 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/gohugoio/hugo/cache/dynacache"
 	"github.com/gohugoio/hugo/common/htime"
+	"github.com/gohugoio/hugo/deps"
 
 	"github.com/spf13/cast"
 )
 
 // New returns a new instance of the time-namespaced template functions.
-func New(timeFormatter htime.TimeFormatter, location *time.Location) *Namespace {
+func New(timeFormatter htime.TimeFormatter, location *time.Location, deps *deps.Deps) *Namespace {
+	if deps.MemCache == nil {
+		panic("must provide MemCache")
+	}
+
 	return &Namespace{
 		timeFormatter: timeFormatter,
 		location:      location,
+		deps:          deps,
+		cacheIn: dynacache.GetOrCreatePartition[string, *time.Location](
+			deps.MemCache,
+			"/tmpl/time/in",
+			dynacache.OptionsPartition{Weight: 30, ClearWhen: dynacache.ClearNever},
+		),
 	}
 }
 
@@ -35,6 +47,8 @@ func New(timeFormatter htime.TimeFormatter, location *time.Location) *Namespace
 type Namespace struct {
 	timeFormatter htime.TimeFormatter
 	location      *time.Location
+	deps          *deps.Deps
+	cacheIn       *dynacache.Partition[string, *time.Location]
 }
 
 // AsTime converts the textual representation of the datetime string into
@@ -71,6 +85,21 @@ func (ns *Namespace) Now() time.Time {
 	return htime.Now()
 }
 
+// In returns the time t in the IANA time zone specified by timeZoneName.
+// If timeZoneName is "" or "UTC", the time is returned in UTC.
+// If timeZoneName is "Local", the time is returned in the system's local time zone.
+// Otherwise, timeZoneName must be a valid IANA location name (e.g., "Europe/Oslo").
+func (ns *Namespace) In(timeZoneName string, t time.Time) (time.Time, error) {
+	location, err := ns.cacheIn.GetOrCreate(dynacache.CleanKey(timeZoneName), func(string) (*time.Location, error) {
+		return time.LoadLocation(timeZoneName)
+	})
+	if err != nil {
+		return time.Time{}, err
+	}
+
+	return t.In(location), nil
+}
+
 // ParseDuration parses the duration string s.
 // A duration string is a possibly signed sequence of
 // decimal numbers, each with optional fraction and a unit suffix,


Test patch:
diff --git a/tpl/time/time_test.go b/tpl/time/time_test.go
index b13e6292964..622f1b12da6 100644
--- a/tpl/time/time_test.go
+++ b/tpl/time/time_test.go
@@ -11,24 +11,31 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package time
+package time_test
 
 import (
 	"strings"
 	"testing"
-	"time"
+	gtime "time"
 
 	qt "github.com/frankban/quicktest"
 
 	"github.com/gohugoio/hugo/common/htime"
+	"github.com/gohugoio/hugo/hugolib"
+	"github.com/gohugoio/hugo/tpl/time"
+
 	translators "github.com/gohugoio/localescompressed"
 )
 
 func TestTimeLocation(t *testing.T) {
 	t.Parallel()
 
-	loc, _ := time.LoadLocation("America/Antigua")
-	ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc)
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{T: t},
+	).Build()
+
+	loc, _ := gtime.LoadLocation("America/Antigua")
+	ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc, b.H.Deps)
 
 	for i, test := range []struct {
 		name     string
@@ -72,7 +79,7 @@ func TestTimeLocation(t *testing.T) {
 				// See https://github.com/gohugoio/hugo/issues/8843#issuecomment-891551447
 				// Drop the location string (last element) when comparing,
 				// as that may change depending on the local locale.
-				timeStr := result.(time.Time).String()
+				timeStr := result.(gtime.Time).String()
 				timeStr = timeStr[:strings.LastIndex(timeStr, " ")]
 				if !strings.HasPrefix(test.expect.(string), timeStr) {
 					t.Errorf("[%d] AsTime got %v but expected %v", i, timeStr, test.expect)
@@ -85,9 +92,14 @@ func TestTimeLocation(t *testing.T) {
 func TestFormat(t *testing.T) {
 	c := qt.New(t)
 
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{T: t},
+	).Build()
+
 	c.Run("UTC", func(c *qt.C) {
 		c.Parallel()
-		ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), time.UTC)
+
+		ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), gtime.UTC, b.H.Deps)
 
 		for i, test := range []struct {
 			layout string
@@ -95,15 +107,15 @@ func TestFormat(t *testing.T) {
 			expect any
 		}{
 			{"Monday, Jan 2, 2006", "2015-01-21", "Wednesday, Jan 21, 2015"},
-			{"Monday, Jan 2, 2006", time.Date(2015, time.January, 21, 0, 0, 0, 0, time.UTC), "Wednesday, Jan 21, 2015"},
+			{"Monday, Jan 2, 2006", gtime.Date(2015, gtime.January, 21, 0, 0, 0, 0, gtime.UTC), "Wednesday, Jan 21, 2015"},
 			{"This isn't a date layout string", "2015-01-21", "This isn't a date layout string"},
 			// The following test case gives either "Tuesday, Jan 20, 2015" or "Monday, Jan 19, 2015" depending on the local time zone
-			{"Monday, Jan 2, 2006", 1421733600, time.Unix(1421733600, 0).Format("Monday, Jan 2, 2006")},
+			{"Monday, Jan 2, 2006", 1421733600, gtime.Unix(1421733600, 0).Format("Monday, Jan 2, 2006")},
 			{"Monday, Jan 2, 2006", 1421733600.123, false},
-			{time.RFC3339, time.Date(2016, time.March, 3, 4, 5, 0, 0, time.UTC), "2016-03-03T04:05:00Z"},
-			{time.RFC1123, time.Date(2016, time.March, 3, 4, 5, 0, 0, time.UTC), "Thu, 03 Mar 2016 04:05:00 UTC"},
-			{time.RFC3339, "Thu, 03 Mar 2016 04:05:00 UTC", "2016-03-03T04:05:00Z"},
-			{time.RFC1123, "2016-03-03T04:05:00Z", "Thu, 03 Mar 2016 04:05:00 UTC"},
+			{gtime.RFC3339, gtime.Date(2016, gtime.March, 3, 4, 5, 0, 0, gtime.UTC), "2016-03-03T04:05:00Z"},
+			{gtime.RFC1123, gtime.Date(2016, gtime.March, 3, 4, 5, 0, 0, gtime.UTC), "Thu, 03 Mar 2016 04:05:00 UTC"},
+			{gtime.RFC3339, "Thu, 03 Mar 2016 04:05:00 UTC", "2016-03-03T04:05:00Z"},
+			{gtime.RFC1123, "2016-03-03T04:05:00Z", "Thu, 03 Mar 2016 04:05:00 UTC"},
 			// Custom layouts, as introduced in Hugo 0.87.
 			{":date_medium", "2015-01-21", "Jan 21, 2015"},
 		} {
@@ -128,9 +140,9 @@ func TestFormat(t *testing.T) {
 	c.Run("TZ America/Los_Angeles", func(c *qt.C) {
 		c.Parallel()
 
-		loc, err := time.LoadLocation("America/Los_Angeles")
+		loc, err := gtime.LoadLocation("America/Los_Angeles")
 		c.Assert(err, qt.IsNil)
-		ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc)
+		ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc, b.H.Deps)
 
 		d, err := ns.Format(":time_full", "2020-03-09T11:00:00")
 
@@ -142,28 +154,32 @@ func TestFormat(t *testing.T) {
 func TestDuration(t *testing.T) {
 	t.Parallel()
 
-	ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), time.UTC)
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{T: t},
+	).Build()
+
+	ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), gtime.UTC, b.H.Deps)
 
 	for i, test := range []struct {
 		unit   any
 		num    any
 		expect any
 	}{
-		{"nanosecond", 10, 10 * time.Nanosecond},
-		{"ns", 10, 10 * time.Nanosecond},
-		{"microsecond", 20, 20 * time.Microsecond},
-		{"us", 20, 20 * time.Microsecond},
-		{"µs", 20, 20 * time.Microsecond},
-		{"millisecond", 20, 20 * time.Millisecond},
-		{"ms", 20, 20 * time.Millisecond},
-		{"second", 30, 30 * time.Second},
-		{"s", 30, 30 * time.Second},
-		{"minute", 20, 20 * time.Minute},
-		{"m", 20, 20 * time.Minute},
-		{"hour", 20, 20 * time.Hour},
-		{"h", 20, 20 * time.Hour},
+		{"nanosecond", 10, 10 * gtime.Nanosecond},
+		{"ns", 10, 10 * gtime.Nanosecond},
+		{"microsecond", 20, 20 * gtime.Microsecond},
+		{"us", 20, 20 * gtime.Microsecond},
+		{"µs", 20, 20 * gtime.Microsecond},
+		{"millisecond", 20, 20 * gtime.Millisecond},
+		{"ms", 20, 20 * gtime.Millisecond},
+		{"second", 30, 30 * gtime.Second},
+		{"s", 30, 30 * gtime.Second},
+		{"minute", 20, 20 * gtime.Minute},
+		{"m", 20, 20 * gtime.Minute},
+		{"hour", 20, 20 * gtime.Hour},
+		{"h", 20, 20 * gtime.Hour},
 		{"hours", 20, false},
-		{"hour", "30", 30 * time.Hour},
+		{"hour", "30", 30 * gtime.Hour},
 	} {
 		result, err := ns.Duration(test.unit, test.num)
 		if b, ok := test.expect.(bool); ok && !b {
@@ -181,3 +197,74 @@ func TestDuration(t *testing.T) {
 		}
 	}
 }
+
+func TestIn(t *testing.T) {
+	t.Parallel()
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{T: t},
+	).Build()
+
+	ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), gtime.UTC, b.H.Deps)
+
+	in := gtime.Date(2025, gtime.March, 31, 15, 0, 0, 0, gtime.UTC)
+
+	tests := []struct {
+		name    string
+		tzn     string // time zone name
+		want    string
+		wantErr bool
+	}{
+		{name: "A", tzn: "America/Denver", want: "2025-03-31T09:00:00-06:00", wantErr: false},
+		{name: "B", tzn: "Australia/Adelaide", want: "2025-04-01T01:30:00+10:30", wantErr: false},
+		{name: "C", tzn: "Europe/Oslo", want: "2025-03-31T17:00:00+02:00", wantErr: false},
+		{name: "D", tzn: "UTC", want: "2025-03-31T15:00:00+00:00", wantErr: false},
+		{name: "E", tzn: "", want: "2025-03-31T15:00:00+00:00", wantErr: false},
+		{name: "F", tzn: "InvalidTimeZoneName", want: "0001-01-01T00:00:00+00:00", wantErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := ns.In(tt.tzn, in)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("time.In() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			got := result.Format("2006-01-02T15:04:05-07:00")
+			if got != tt.want {
+				t.Errorf("time.In() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+// For benchmark tests below.
+var timeZoneNames []string = []string{"America/New_York", "Europe/Oslo", "Australia/Sydney", "UTC", "Local"}
+
+func BenchmarkInWithCaching(b *testing.B) {
+	bb := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{T: b},
+	).Build()
+
+	ns := time.New(htime.NewTimeFormatter(translators.GetTranslator("en")), gtime.UTC, bb.H.Deps)
+
+	for i := 0; i < b.N; i++ {
+		timeZoneName := timeZoneNames[i%len(timeZoneNames)]
+		_, err := ns.In(timeZoneName, gtime.Now())
+		if err != nil {
+			b.Fatalf("Error during benchmark: %v", err)
+		}
+	}
+}
+
+func BenchmarkInWithoutCaching(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		timeZoneName := timeZoneNames[i%len(timeZoneNames)]
+		location, err := gtime.LoadLocation(timeZoneName)
+		if err != nil {
+			b.Fatalf("Error during benchmark: %v", err)
+		}
+
+		_ = gtime.Now().In(location)
+	}
+}