How should I store dates in EK9 — UTC or local time?
← Date, Time, and Duration · Ref: Q547
Always store in UTC. Convert to local time only for display. This is the universal best practice and EK9 makes it natural.
WHY UTC FOR STORAGE
1. Unambiguous: UTC has no daylight saving transitions. 14:00 UTC is always 14:00 UTC.
2. Comparable: two UTC timestamps can be directly compared without timezone conversion.
3. Arithmetic: adding PT1H to a UTC time always moves forward exactly one hour. With local times, adding an hour across a DST boundary can give unexpected results.
4. Portable: UTC means the same thing everywhere. 'EST' could be US Eastern or Australian Eastern.
THE PATTERN
Store: always as UTC DateTime literals or UTC-constructed values.
created <- 2024-06-15T14:00:00Z updated <- DateTime().now()
Display: convert to user's timezone with withSameInstant:
userZone <- 'America/New_York' displayTime <- stored.withSameInstant(userZone)
Format: use Locale for regional formatting:
enUS <- Locale('en_US') formatted <- enUS.longFormat(displayTime)
FULL PIPELINE
stored <- 2024-06-15T14:00:00Z local <- stored.withSameInstant('Europe/Berlin') deDE <- Locale('de_DE') stdout.println(deDE.longFormat(local))
Store (UTC) to display (timezone) to format (locale) is the three-step pipeline.
See Q541 for timezone basics. See Q545 for timezone conversion. See Q546 for withSameInstant vs withZone. See Q549 for locale formatting. See Q553 for common timezone mistakes.
Example
defines module qa.store.utc.display.local defines program StoreUtcDisplayLocalDemo() stdout <- Stdout() // STORE: always in UTC orderCreated <- 2024-06-15T14:30:00Z orderShipped <- 2024-06-16T09:15:00Z stdout.println(`Stored (UTC): ${orderCreated}`) // COMPARE: UTC timestamps compare correctly require orderShipped > orderCreated elapsed <- orderShipped - orderCreated stdout.println(`Time to ship: ${elapsed}`) // DISPLAY: convert to user's timezone userZone <- "America/New_York" localCreated <- orderCreated.withSameInstant(userZone) localShipped <- orderShipped.withSameInstant(userZone) stdout.println(`User sees created: ${localCreated}`) stdout.println(`User sees shipped: ${localShipped}`) // FORMAT: locale-aware display enUS <- Locale("en_US") stdout.println(`Formatted: ${enUS.longFormat(localCreated)}`) // Full pipeline: store -> timezone -> locale deDE <- Locale("de_DE") berlinTime <- orderCreated.withSameInstant("Europe/Berlin") stdout.println(`German user sees: ${deDE.longFormat(berlinTime)}`) // Multiple users see different times for the same event tokyoTime <- orderCreated.withSameInstant("Asia/Tokyo") jaJP <- Locale("ja_JP") stdout.println(`Tokyo user sees: ${jaJP.longFormat(tokyoTime)}`) // All stored values stay UTC for correct comparison require localCreated == orderCreated require berlinTime == orderCreated require tokyoTime == orderCreated
Common mistakes
E50060 — DateTime has no toLocalTime() method. Use withSameInstant() to convert UTC timestamps to a local timezone for display. See ek9 -h E50060 for details.
Incorrect:
localCreated <- orderCreated.toLocalTime(userZone)
Correct:
localCreated <- orderCreated.withSameInstant(userZone)
Other ways to ask this
- Should I store timestamps in UTC?
- What is the best practice for storing dates?
- How do I handle the store UTC display local pattern?
Coming from another language?
All languages: the 'store UTC, display local' pattern is universal best practice. Java: Instant for storage, ZonedDateTime for display. Python: datetime.utcnow() (deprecated) or datetime.now(UTC). JavaScript: Date internally stores UTC, toLocaleString() for display. Go: time.UTC for storage, In(loc) for display. EK9: DateTime literal with Z suffix for storage, withSameInstant() for display, Locale for formatting.
Keywords: timezone, duration, local, display, time, best practice, locale, store, pattern, utc