Go'da time Paketi: time.Time, Duration, Referans Zamanla Format ("2006-01-02"), Time Zone ve Monotonic Clock
Go'da tarih biçimi yyyy-MM-dd gibi kodlarla değil, referans zaman 2006-01-02 15:04:05'in nasıl yazılacağı gösterilerek verilir; "yyyy-MM-dd" hata vermez, olduğu gibi yazılır. Duration, json/v2'nin Duration'a varsayılan biçim vermemesi, time zone'lar ve 11'deki üç saatlik kayma, scratch image'da ve Windows'ta time/tzdata, depo saatine göre teslim tahmini, monotonic clock ile Equal ve time.After tuzağı.
Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; Bookstore module'ünün go satırı 1.26.0'dır ve kod Go 1.26.11 ile de derlenip testlerden geçer. encoding/json/v2 Go 1.27 ile geldiği için onu kullanan lab //go:build go1.27 ile ayrılmıştır. time.DateOnly ve time.DateTime Go 1.20, testing/synctest Go 1.25 ile gelmiştir. Container denemesi scratch ve gcr.io/distroless/static-debian13:nonroot image'larıyla linux/amd64 için yapıldı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) bir C# geliştiricisi sipariş tarihini log'a yazmak için alışkanlıkla şu satırı yazıyor: t.Format("yyyy-MM-dd HH:mm"). Derleyici bir şey demiyor, test de patlamıyor. Log'da ne görürsün?
// swapped sabit değil değişken, bu yüzden go vet onu görmez.
var swapped = "2006-02-01 03:04"
func main() {
ist, err := time.LoadLocation("Europe/Istanbul")
if err != nil {
panic(err)
}
t := time.Date(2026, 11, 14, 21, 5, 7, 123456789, ist)
fmt.Println(t.Format("yyyy-MM-dd HH:mm")) // C# alışkanlığı: hata da yok, tarih de
fmt.Println(t.Format(swapped)) // gün ve ay yer değiştirmiş, 12 saatlik düzen
fmt.Println(t.Format("2006-01-02 15:04"))
fmt.Println(t.Format("02.01.2006 Monday"))
fmt.Println(t.Format(time.RFC3339), "|", t.Format(time.RFC3339Nano))
fmt.Println(t.Format(time.DateOnly), "|", t.Format(time.Kitchen))
for _, s := range []string{"2026-11-14", "14.11.2026", "2026-11-31"} {
d, err := time.Parse(time.DateOnly, s)
fmt.Println(d, err)
}
}
$ go run ./labs/16-layout
yyyy-MM-dd HH:mm
2026-14-11 09:05
2026-11-14 21:05
14.11.2026 Saturday
2026-11-14T21:05:07+03:00 | 2026-11-14T21:05:07.123456789+03:00
2026-11-14 | 9:05PM
2026-11-14 00:00:00 +0000 UTC <nil>
0001-01-01 00:00:00 +0000 UTC parsing time "14.11.2026" as "2006-01-02": cannot parse "14.11.2026" as "2006"
0001-01-01 00:00:00 +0000 UTC parsing time "2026-11-31": day out of range
İlk satır tarih değil, layout'un kendisi. Go'da biçim yyyy, MM, dd gibi harf kodlarıyla değil, sabit bir referans zamanın nasıl yazılacağı gösterilerek tarif ediliyor ve bu harflerin hiçbiri o referansın parçası olmadığı için olduğu gibi yazıldı. İkinci satır daha tehlikeli: gün ile ay yer değiştirmiş ve 21:05 "09:05" olmuş. Bu yazıda time package'ını görüyoruz: referans zamanla format ve parse, Duration, time zone'lar ve container'da time zone verisi, monotonic clock ve Equal, timer'lar. Bookstore'a da depo saatine göre çalışan bir teslim tahmini ekliyoruz.
Referans Zaman: 2006-01-02 15:04:05
Referans zaman Mon Jan 2 15:04:05 MST 2006. Sırası Amerikan tarih yazımına göre 1, 2, 3, 4, 5, 6, 7: ay 01, gün 02, saat 03 (24 saatlik düzende 15), dakika 04, saniye 05, yıl 06, zone -0700. Layout'a hangi parçayı nasıl yazarsan çıktıda o parça o biçimde geliyor: "02.01.2006" Türkçe tarih düzeni, "Monday" gün adı, "Jan" kısa ay adı. Gün ve ay adları her zaman İngilizce; Go'nun standart kütüphanesinde Türkçe ay adı yok, gerekiyorsa küçük bir tablo yazarsın.
Çoğu zaman layout yazmana gerek yok, sabitler var: API ve log için time.RFC3339 (ya da kesirli saniyeyle RFC3339Nano), sadece tarih için time.DateOnly, tarih ve saat için time.DateTime. RFC3339, RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 3339'un tanımladığı biçim ve ISO'nun (Uluslararası Standardizasyon Örgütü) tarih-saat standardı 8601'in internette kullanılan sade bir profili; JSON'da time.Time da bu biçimle yazılıyor. Parse ters yönde aynı layout'u kullanıyor ve hatası neyin uymadığını tam olarak söylüyor; 31 Kasım gibi olmayan bir günü de reddediyor. go vet'in timeformat kontrolü en sık hatayı yakalıyor; lab'da layout'u bu yüzden bir değişkene koydum, literal yazınca vet şunu diyor:
$ go vet ./labs/16-layout
labs/16-layout/main.go:17:24: 2006-02-01 should be 2006-01-02
C# Temelleri serisinin "C# Tarih ve Saat" yazısındaki DateTime.ToString("yyyy-MM-dd")'nin karşılığı yani t.Format(time.DateOnly).
Duration ve Zaman Aritmetiği
time.Duration nanosaniye cinsinden bir int64; C#'taki TimeSpan'in karşılığı. t.Add(d), t.Sub(u), time.Since(t) ve time.Until(t) günlük işlerin çoğunu görüyor; 13'teki BOOKSTORE_DRAIN_DELAY ayarını da time.ParseDuration okuyordu. Go'nun tip kuralları burada bir kere seni durduracak:
func main() {
attempt := 3
fmt.Println(attempt * time.Second) // int çarpı Duration
}
$ go build .
# dur
./main.go:10:14: invalid operation: attempt * time.Second (mismatched types int and time.Duration)
3 * time.Second derleniyor çünkü 3 tipsiz bir sabit; attempt ise int tipinde bir değişken ve Go örtük dönüşüm yapmıyor. Doğrusu time.Duration(attempt) * time.Second. Duration'ı JSON'a yazarken de dikkat: v1 nanosaniyeyi sayı olarak yazıyor, Go 1.27'nin json/v2'si ise varsayılan bir biçim seçmiyor:
func main() {
p := retryPolicy{Delay: 1500 * time.Millisecond}
b, err := jsonv1.Marshal(p)
fmt.Println("v1: ", string(b), err)
b, err = json.Marshal(p)
fmt.Println("v2: ", string(b), err)
b, err = json.Marshal(p, jsonv1.FormatDurationAsNano(true))
fmt.Println("v2 nano: ", string(b), err)
b, err = json.Marshal(retryPolicyFormat(p))
fmt.Println("v2 tag: ", string(b), err)
fmt.Println("String():", p.Delay) // ParseDuration'ın geri okuduğu biçim
}
$ go run ./labs/16-durationjson
v1: {"delay":1500000000} <nil>
v2: {"delay" json: cannot marshal from Go time.Duration within "/delay": no default representation
v2 nano: {"delay":1500000000} <nil>
v2 tag: json: cannot marshal from Go main.retryPolicyFormat: Go struct field Delay has unsupported `format` tag option
String(): 1.5s
Deney döneminde json/v2 format:units gibi struct tag option'larını kabul ediyordu; Go 1.27.4'ün kaynak kodundaki nota göre bu destek, dile ileride tipli struct tag'leri gelebileceği için ilk sürümden çıkarıldı (Go issue #79071). Bugün iki yol var: v1 package'ındaki FormatDurationAsNano(true) option'ı ya da değeri string tutup ParseDuration ile okumak. Hatalı Marshal'ın yarım bir çıktı ({"delay") da döndüğüne dikkat; hata varken çıktıyı kullanma.
Time Zone: Tek An, Birçok Duvar Saati
time.Time bir anı ve o anı hangi time zone'da göstereceğini birlikte taşıyor. In anı değiştirmeden gösterimi değiştiriyor, UTC de onun kısa yolu:
func main() {
ist := must(time.LoadLocation("Europe/Istanbul"))
berlin := must(time.LoadLocation("Europe/Berlin"))
placed := time.Date(2026, 11, 14, 21, 30, 0, 0, ist)
fmt.Println(placed, "|", placed.UTC(), "|", placed.In(berlin))
fmt.Println(placed.Equal(placed.UTC()), placed == placed.UTC())
// Offset'siz Parse UTC demek; ParseInLocation yerel duvar saati demek.
a, _ := time.Parse(time.DateTime, "2026-11-14 21:30:00")
b, _ := time.ParseInLocation(time.DateTime, "2026-11-14 21:30:00", ist)
fmt.Println(a, "|", b, "|", a.Sub(b))
// 11. yazı: datetime2 duvar saatini tutar, offset'i atar.
stored := time.Date(placed.Year(), placed.Month(), placed.Day(), placed.Hour(), placed.Minute(), 0, 0, time.UTC)
fmt.Println("read back:", stored, "shift:", stored.Sub(placed))
// Truncate mutlak zamanla çalışır, yani "gün" 00:00 UTC'de başlar.
fmt.Println(placed.Truncate(24 * time.Hour))
// Berlin 2026-10-25'te yaz saatinden çıkıyor.
noon := time.Date(2026, 10, 24, 12, 0, 0, 0, berlin)
fmt.Println(noon.Add(24*time.Hour), "|", noon.AddDate(0, 0, 1))
}
$ go run ./labs/16-zones
2026-11-14 21:30:00 +0300 +03 | 2026-11-14 18:30:00 +0000 UTC | 2026-11-14 19:30:00 +0100 CET
true false
2026-11-14 21:30:00 +0000 UTC | 2026-11-14 21:30:00 +0300 +03 | 3h0m0s
read back: 2026-11-14 21:30:00 +0000 UTC shift: 3h0m0s
2026-11-14 03:00:00 +0300 +03
2026-10-25 11:00:00 +0100 CET | 2026-10-25 12:00:00 +0100 CET
İlk satırdaki üç gösterim, yani Istanbul, UTC (Coordinated Universal Time; saat dilimi farkı olmayan referans saat) ve Berlin, aynı anı gösteriyor; Equal bunu biliyor, == bilmiyor. time.Parse offset'i olmayan bir metni UTC kabul ediyor, ParseInLocation verilen zone'un duvar saati olarak okuyor; aynı "21:30" iki fonksiyonda üç saat farklı iki an oldu. 11'deki hata tam olarak dördüncü satırdaki durum: driver anı offset'iyle gönderdi, datetime2 kolonu duvar saatini tutup offset'i attı ve okurken UTC sanıldı. Kural basit: veritabanına ve API'ye UTC yaz, yerel saate sadece gösterirken çevir.
Son iki satır iki küçük tuzak. Truncate(24 * time.Hour) "günün başı"nı vermiyor, çünkü mutlak zaman üzerinde çalışıyor ve Istanbul'da 03:00 çıktı; yerel gece yarısı için time.Date(y, m, d, 0, 0, 0, 0, loc) kullan. Türkiye 2016'dan beri yaz saati uygulamıyor ama Berlin 25 Ekim'de saati geri aldı ve Add(24*time.Hour) 11:00'e düştü; takvim günü eklemek için AddDate(0, 0, 1).
Container'da ve Windows'ta Time Zone Verisi
LoadLocation IANA (Internet Assigned Numbers Authority; time zone veritabanını da yayımlayan kuruluş) time zone veritabanını sırayla ZONEINFO ortam değişkeninde, Unix'te /usr/share/zoneinfo gibi sistem klasörlerinde, binary'ye gömülü time/tzdata'da ve $GOROOT/lib/time/zoneinfo.zip'te arıyor. Windows için kaynak kodda sistem kaynağı yok; Go kurulu olmayan bir Windows sunucuda bu veri ancak time/tzdata import edilmişse var. scratch image'ında da hiçbiri yok:
$ docker run --rm goseries-tz:plain
error: unknown time zone Europe/Istanbul
$ docker run --rm goseries-tz:embed # -tags timetzdata
2026-11-14 21:30:00 +0300 +03
$ docker run --rm -e TZ=Europe/Istanbul goseries-tz:plain
error: unknown time zone Europe/Istanbul
13'teki distroless image'ı time zone verisini içeriyor, aynı binary orada çalıştı. Windows'un kendi time zone id'leri ("Turkey Standard Time") ile IANA adları arasındaki farkı C# ile Günlük .NET API'leri serisinin tarih ve saat yazısında anlatmıştık; Go her platformda IANA adını bekliyor. Yine de Bookstore'un main'ine tek satır ekledim ve deploy edildiği yerden bağımsız hâle getirdim:
_ "time/tzdata" // yaklaşık 450 KB: time zone'lar scratch image'larda ve Windows sunucularda çalışır
Bedeli: Linux binary'si 13.167.005 byte'tan 13.578.629 byte'a çıktı, yani yaklaşık 400 KB büyüdü. Depo time zone'u artık BOOKSTORE_WAREHOUSE_TZ ayarından geliyor (varsayılan Europe/Istanbul) ve 13'teki config.FromEnv yanlış bir adı açılışta yakalıyor:
$ BOOKSTORE_WAREHOUSE_TZ=Europe/Istanbol ./bin/bookstore-api
bookstore-api: BOOKSTORE_WAREHOUSE_TZ: unknown time zone Europe/Istanbol
Bookstore: Teslim Tahmini
Kural şu: depo saatiyle 15:00'ten önce verilen sipariş aynı iş günü kargoya çıkıyor, sonrakiler bir sonraki iş günü; teslim kargodan iki iş günü sonra. Hesap depo saatinde yapılmalı, çünkü sunucu UTC'de çalışıyor ve sipariş zamanı UTC'de saklanıyor:
// Estimate teslim tarihini r.Loc'ta gece yarısı olarak döner.
func (r DeliveryRule) Estimate(placed time.Time) time.Time {
loc := r.Loc
if loc == nil {
loc = time.UTC
}
local := placed.In(loc) // depo saatinde aynı an
day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
if local.Hour() >= r.CutoffHour || !isBusinessDay(day) {
day = nextBusinessDay(day)
}
for range r.TransitDays {
day = nextBusinessDay(day)
}
return day
}
// nextBusinessDay takvim gününü 24*time.Hour ile değil AddDate ile ekler,
// böylece yaz saati değişikliği sonucu gece yarısından kaydıramaz.
func nextBusinessDay(day time.Time) time.Time {
day = day.AddDate(0, 0, 1)
for !isBusinessDay(day) {
day = day.AddDate(0, 0, 1)
}
return day
}
{"friday before cutoff", time.Date(2026, 11, 13, 14, 59, 0, 0, ist), "2026-11-17"},
{"friday at cutoff", time.Date(2026, 11, 13, 15, 0, 0, 0, ist), "2026-11-18"},
{"saturday", time.Date(2026, 11, 14, 10, 0, 0, 0, ist), "2026-11-18"},
{"utc noon is 15:00 in Istanbul", time.Date(2026, 11, 16, 12, 0, 0, 0, time.UTC), "2026-11-19"},
{"utc 21:30 is already tuesday", time.Date(2026, 11, 16, 21, 30, 0, 0, time.UTC), "2026-11-19"},
Dördüncü satır asıl testi yapıyor: UTC'de 12:00, Istanbul'da 15:00 ve kesim saatine takılıyor; hesabı UTC'de yapan bir implementasyon 18'ini dönerdi. Saat, 12'deki gibi parametre olarak geliyor; test gerçek saate hiç bakmıyor. Sipariş cevabı artık tahmini teslim tarihini de taşıyor:
$ curl -s -X POST -H 'Content-Type: application/json' --data @internal/httpapi/testdata/place_order.json http://127.0.0.1:52715/api/orders
{"id":"ORD-1001","customerId":"ada","status":"Pending","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":34990,"quantity":2}],"total":69980,"createdAt":"2026-11-15T03:09:50.835206Z","estimatedDelivery":"2026-11-18"}
Sipariş Pazar sabahı verildi: Pazartesi kargoya çıkıyor, Çarşamba teslim. createdAt UTC ve RFC 3339, estimatedDelivery ise bir saat değil bir gün olduğu için DateOnly biçiminde; C#'taki DateOnly gibi ayrı bir tip Go'da yok.
Monotonic Clock ve Equal
12'de söz verdiğim konu: time.Now() iki saat okuyor. Biri duvar saati, NTP (Network Time Protocol; bilgisayar saatini ağ üzerinden eşitleyen protokol) ya da kullanıcı onu ileri geri alabilir. Diğeri sadece ileri giden monotonic clock ve süre ölçümleri bunu kullanıyor:
func main() {
now := time.Now()
fmt.Println(now) // sondaki kısım monotonic okumayı gösterir: m=+...
wall := now.Round(0) // Round(0) monotonic okumayı siler
fmt.Println(now == wall, now.Equal(wall))
b, _ := json.Marshal(now)
var back time.Time
_ = json.Unmarshal(b, &back)
fmt.Println(string(b), now == back, now.Equal(back))
seen := map[time.Time]bool{now: true}
fmt.Println(seen[back], seen[now])
start := time.Now()
time.Sleep(20 * time.Millisecond)
fmt.Println(time.Since(start).Round(time.Millisecond)) // monotonic clock'u kullanır
}
$ go run ./labs/16-monotonic
2026-11-15 06:07:16.205141 +0300 +03 m=+0.000080834
false true
"2026-11-15T06:07:16.205141+03:00" false true
false true
21ms
m=+0.000080834 monotonic okuma. Round(0) onu siliyor ve aynı an == ile artık eşit değil. JSON'dan geri okunan değer de monotonic okumayı ve location pointer'ını kaybediyor; == ve map key'i bu yüzden yanlış sonuç veriyor, Equal doğru. time.Time'ı map key'i yapman gerekiyorsa önce t.UTC().Round(0) gibi tek bir biçime getir ya da UnixNano() kullan. time.Since monotonic okumayı kullandığı için saat ayarlansa bile negatif süre üretmiyor.
Timer, Ticker ve time.After
14'te gördüğümüz gibi Go 1.23'ten beri durdurulmamış timer'lar ve ticker'lar da GC (garbage collector; kullanılmayan belleği toplayan bileşen) tarafından toplanıyor; time.After'ı döngüde kullanmak artık bir bellek sızıntısı değil. Ama bir mantık hatası olarak kalıyor:
// receiveAfter her iterasyonda yeni bir timer başlatır.
func receiveAfter(ch <-chan int, timeout time.Duration) int {
n := 0
for {
select {
case _, ok := <-ch:
if !ok {
return n
}
n++
case <-time.After(timeout): // her mesajla sıfırlanır
return n
}
}
}
$ go test -v -run TestTimeouts ./labs/16-timers
=== RUN TestTimeouts
timers_test.go:81: time.After got 10 updates in 4s
timers_test.go:81: deadline got 2 updates in 1s
--- PASS: TestTimeouts (0.00s)
Niyet "en fazla bir saniye bekle"; ama time.After her iterasyonda yeni bir timer açtığı için her mesaj süreyi sıfırladı ve döngü 4 saniye sürdü. receiveDeadline döngüden önce tek bir time.NewTimer açıyor ve defer Stop ile kapatıyor. Test 12'deki synctest içinde çalıştığı için "4s" sahte saatte geçti ve test 0,00 saniyede bitti. İlk denemede bir şey daha yakaladı: receiveDeadline vazgeçince producer goroutine'i göndermeye çalışırken bloklanıyordu ve synctest testi "deadlock: main bubble goroutine has exited but blocked goroutines remain" ile düşürdü; producer'a bir context verince düzeldi. Belirli aralıklarla iş yapmak için time.NewTicker kullan ve işin bitince Stop et.
Üretimde Dikkat
Saklarken ve taşırken UTC, gösterirken yerel saat. 11'deki üç saatlik kayma bu kuralın bir katmanda unutulmasıydı. SQL Server'da offset'i de saklamak istiyorsan kolon datetimeoffset olmalı.
Time zone verisini binary'ye ya da image'a koy ve ilk açılışta kontrol et. LoadLocation hatasını config aşamasında yakalamak, ilk siparişte bir 500 görmekten iyi. IANA veritabanı yılda birkaç kez güncelleniyor; time/tzdata Go sürümüyle birlikte geliyor, yani Go'yu güncel tutmak bu veriyi de güncel tutuyor.
Zamanı enjekte et. orders.Service.now ve DeliveryRule.Estimate'in parametre alması testlerin gerçek saate bağlı olmamasını sağlıyor; zaman bağımlı goroutine'ler için synctest var.
Sık Düşülen Tuzaklar
"yyyy-MM-dd" yazmak. Hata vermez, layout'u olduğu gibi yazar; time.DateOnly kullan ve go vet'i CI'da çalıştır.
time.Time'ı == ile karşılaştırmak ya da map key'i yapmak. Monotonic okuma ve location farkı aynı anı farklı gösterir; Equal kullan.
scratch image'da ya da Windows sunucuda LoadLocation. Time zone verisi yoksa "unknown time zone" alırsın; time/tzdata import et.
Gün hesabını 24*time.Hour ile yapmak. Yaz saati değişen zone'larda gün 23 ya da 25 saat sürer; AddDate ve time.Date kullan.
Döngüde time.After ile genel bir timeout kurmak. Her iterasyonda sıfırlanır; döngüden önce bir timer ya da context.WithTimeout kullan.
Küçük Bir Deneme
★☆☆ labs/16-layout'a Türkçe ay adıyla "14 Kasım 2026 Cumartesi" yazan bir fonksiyon ekle: t.Month() ve t.Weekday() ile iki küçük tablo yeter.
★★☆ DeliveryRule'a resmî tatil listesi ekle ([]time.Time ya da map[string]bool ile "2006-01-02" anahtarları). 28 Ekim öğleden sonra ve 29 Ekim için tablo testine satır ekle; map key'i olarak neden time.Time değil de string seçtiğini açıkla.
★★★ orders.Order'a teslim tahminini saklayan bir field ekle ve sqlstore'da date tipinde bir kolona yaz. go-mssqldb'nin date kolonunu hangi location ile okuduğunu bir entegrasyon testiyle kontrol et.
Zamanla ilgili hesaplar bitti; sıradaki yazıda bu tarihleri ve kitapları sıralıyoruz. slices, maps, sort ve cmp package'larıyla sıralama, arama ve gruplama yapacak, LINQ'e (Language Integrated Query; C#'ta collection'ları sorgulama syntax'ı) alışkın bir geliştiricinin Go'da bunları nasıl yazdığına bakacağız.