Skip to content

Dates and time zones

The servers run in UTC: production containers set no TZ or user.timezone, and storage, queries and comparisons rely on that. Shops don't: a UK shop is on British Summer Time for half the year, and a shop can be anywhere. So store and compare in UTC, and convert to the shop's zone only when a person reads the time (a page, an admin screen, an email, an export).

Don't fix this by setting the JVM's default zone. One JVM serves shops in different zones, and code that works out "today" or a month's boundaries would silently change meaning.

Which zone

ShopZones (in common) holds the one rule for which zone a shop is in. Everything that shows or reasons about a shop's time uses it: pages, emails, admin dates (#bx, #shopDates), event tickets and the scan app's "today", POS day totals, promotion schedules, donations, search analytics and experiments, and the storefront's LocaleContextHolder zone.

  1. The site's defaultTimeZone, when it is a zone id ZoneId.of accepts. An unknown id counts as missing, so a typo in a site's settings can't break a page.
  2. Else the zone the site's defaultLocale country implies (ShopZones.zoneForLocale: en_GB is Europe/London, nl_NL Europe/Amsterdam). The map lists only countries with one zone; en_US, en_CA and en_AU imply none.
  3. Else Europe/London.
Call Zone
ShopZones.of(siteContext) The rule above. Never null.
ShopZones.resolve(siteContext) The zone plus where it came from: SITE (the shop chose it), LOCALE or DEFAULT (assumed). The admin and MCP show this; the product editor's Event panel warns on DEFAULT.
ShopZones.orDefault(zone, fallback) A ZoneId, TimeZone or zone id string, else fallback. For a time that belongs to a store location (Location.timeZone): a collection slot, opening hours.

Don't add another fallback: a caller that needs the shop's zone calls ShopZones.of. The zone is set on Settings › Site identity, System › Tenants, or MCP manageBranding setIdentity timeZone; SiteTimeZones (also common) holds the editing helpers: validating a submitted id, the picker's options and the display label. New sites get their locale's zone, and system update 112 gave existing sites with none the zone their locale implies.

In templates: #shopDates

Thymeleaf's #dates, #calendars and #temporals format in the JVM's zone, which is UTC in production, so they show every time an hour early in summer. Storefront, admin and email templates use #shopDates instead. TemplatesUseShopDatesTest (commerce-core) scans the templates of commerce-core, web-mvc, web-admin and web and fails the build on any #dates.*, #calendars.* or #temporals.* call other than #dates.createNow().

Expression Result for 07:38 UTC on 10 Oct 2026, UK shop
${#shopDates.format(order.submitted, 'd MMM, HH:mm')} 10 Oct, 08:38
${#shopDates.formatISO(order.submitted)} 2026-10-10T08:38:00.000+01:00 (for <time datetime>)
${#shopDates.format(slot.start, 'HH:mm', location.timeZone)} The time in a store's own zone; a missing or unknown zone falls back to the shop's
${#shopDates.formatDateOnly(product.attributes[dueDate], 'd MMM yyyy')} A calendar date with no time of day (see below)
${#shopDates.zone()} Europe/London

Patterns are SimpleDateFormat patterns, exactly as for #dates, so swapping #dates.format for #shopDates.format changes nothing but the zone (z in a pattern prints BST/GMT). It accepts Date, Calendar, Instant, ZonedDateTime and OffsetDateTime. LocalDateTime and LocalDate are wall-clock values and print unchanged. A null date gives null, which th:text renders as nothing.

Date-only values. A date with no time of day typed into a form (a date of birth, a bill's due date, a fundraiser's end date) is parsed as midnight in the server's zone. Formatting it in a shop west of UTC would move it to the day before, so formatDateOnly reads it back in the zone it was written in. Use it only for those; anything that happened at a moment uses format.

Where the zone comes from

ShopDatesDialect (commerce-core) is an IDialect bean, so Spring Boot adds it to the engine of every app that scans commerce-core (web, rest, worker; not auth, which has no shop). EmailTemplateRenderer adds it to its private email engine. For each template run it takes the zone from, in order:

  1. the shopTimeZone model variable (a ZoneId or zone id string), when a renderer pins it. EmailTemplateRenderer always does, from the SiteContext it renders for, because emails go out from the worker on any thread;
  2. the siteContext model variable, which admin pages carry;
  3. the current request's or thread's site, through SiteContextProducer: an in-memory read, with no I/O, so it keeps to "templates only read the model";
  4. Europe/London.

Render tests that build their own SpringTemplateEngine must addDialect(new ShopDatesDialect()). The no-argument constructor reads the site from TenantContext, so a test sets a zone with TenantContext.setCurrentTenant(site) or a shopTimeZone variable.

Admin screens built on the bx- components can also use #bx.date / #bx.dateTime / #bx.relative (Admin UI). They use the same ShopZones rule.

In Java

Text built in Java for people to read needs the zone as well: a SimpleDateFormat or DateTimeFormatter without one uses UTC. Either format with new ShopDates(ShopZones.of(siteContext), locale).format(date, pattern) (commerce-core and above), or attach the zone yourself: DateTimeFormatter.ofPattern(...).format(instant.atZone(ShopZones.of(siteContext))). Examples: the customer's return deadline (OrderReturnRequestExtension), experiment dates (ExperimentViews) and the sales XLSX (ExcelExportService.generateSalesReport(items, currency, zone)).

Better still, put the Date on the model and let the template format it with #shopDates.

Machine-readable output (REST payloads, ISO timestamps, logs, file names) can stay in UTC.

Day, month and tax-year boundaries

The same problem in another form: "today", "this month" or "this tax year" worked out with LocalDate.now(), ZonedDateTime.now() or ZoneId.systemDefault() is the UTC day, which is a day out for the first hour after midnight in BST. Never use them for a boundary.

Call Use
ShopZones.today(siteContext) The shop's date: default report dates, "this year", file names a person reads
ShopZones.startOfDay(date, siteContext) Inclusive start of a staff-picked date range
ShopZones.endOfDay(date, siteContext) Exclusive end of a range that includes date (the next day's start)
GiftAidKeyGenerator.ukToday(), ukDate(instant), ukStartOfDay(date) Gift Aid: always UK time, whatever the shop's zone, since the tax year turns at midnight UK time on 5/6 April
GiftAidKeyGenerator.currentTaxPeriod() / claimWindowStart(clock) The current tax period ("2026/27") and the start of the four-year claim window

ShopZones.today and GiftAidKeyGenerator.ukToday take a Clock for tests; test boundaries at a BST instant such as 2026-04-05T23:30:00Z (00:30 on 6 April in the UK).