Get the Start and End of Day in a Timezone

To get the start of a day in a zone, use zonedDateTime.startOfDay(); the end is the next day's start (exclusive). This handles DST days where midnight can shift. Part of Timezone Offset Math Explained.

Why this scenario is tricky

"Start of day" sounds like "set the time to 00:00:00," and that intuition is exactly what makes the legacy approach wrong. The start of a day is only midnight in a specific zone, and the instant it corresponds to differs by zone — midnight in São Paulo is a different moment than midnight in Tokyo. Zero the time fields on a legacy Date and you get midnight in the host zone, so a query built on it buckets a user's day differently on a UTC server than in the user's own browser. Worse, on a daylight-saving spring-forward day, midnight may not exist at all: the clock can jump from 23:59 straight to 01:00, so a literal 00:00 is an invalid wall time.

Temporal handles both problems with startOfDay() on a ZonedDateTime, which returns the first valid instant of the civil day in that zone — normally midnight, but the post-transition time on days when midnight is skipped. Anchoring the day boundary in an explicit zone, rather than the host's, is what makes the result consistent across environments. The trick is to keep the zone explicit and to trust startOfDay rather than hard-coding a midnight time-of-day.

Day boundaries are a favourite source of reporting bugs. "Start of day" is not always 00:00: on a spring-forward day in zones that transition at midnight, 00:00 does not exist and the day begins at 01:00. Setting the time to midnight by hand produces an invalid instant; startOfDay() asks the timezone database for the real first moment.

Midnight is not always 00:00Hand-setting midnight can hit a DST gapMidnight is not always 00:00set hours to 0,0,0invalid on some DST dayszdt.startOfDay()the real first instantLet Temporal find the day's first valid moment rather than assuming midnight exists.

Minimal working solution

Temporal.Now.zonedDateTimeISO(zone).startOfDay() gives the first instant of today in the named zone, and adding one day (start.add({ days: 1 })) gives the exclusive end — the start of tomorrow. That half-open [start, nextStart) pair is the correct shape for "everything that happened today," because a record timestamped at exactly the next midnight belongs to tomorrow, not today, and the half-open range expresses that without an off-by-one. Using add({ days: 1 }) rather than "end of day at 23:59:59.999" also sidesteps the question of how many nines of precision to write and correctly spans days that are 23 or 25 hours long.

For an arbitrary date rather than today, build the day's PlainDate, attach the zone with toZonedDateTime(zone), and call startOfDay(). Keeping the date civil until the final zoning step means the "which day" decision and the "which instant" decision stay separate and each stays correct — the day is unambiguous as a PlainDate, and the instant is zone-correct via startOfDay.

import { Temporal } from '@js-temporal/polyfill';
const day = Temporal.Now.zonedDateTimeISO('America/Sao_Paulo');
const start = day.startOfDay();          // first instant of the day
const end = start.add({ days: 1 });      // exclusive end = next day's start

Day boundariesstartOfDay, then next day's startDay boundarieszoned daystartOfDay()+1 day = end

Full production version

The production helper takes a PlainDate and a zone and returns the half-open bounds as a pair of ZonedDateTimes (or their instants, for a database query). Returning both bounds together encourages callers to use the half-open range consistently — >= start AND < end — rather than reinventing an inclusive end that double-counts the midnight boundary. Because the bounds come from startOfDay, they are correct on transition days, and because the zone is a parameter, the same helper serves per-user day boundaries: a report for a Sydney user uses Sydney's day, a São Paulo user's uses theirs, and a server in UTC computes both correctly.

This day-bounds primitive underlies a surprising amount of application logic: "today's orders," "did this happen on the user's birthday," daily aggregation, streak counting, and rate limits that reset at local midnight. All of them need the day boundary anchored in the user's zone to match what the user perceives as "today," and all of them break subtly if the boundary is computed in the server's zone. Centralizing the calculation in one zone-aware helper means every one of these features inherits the correct behavior, including the DST edge, rather than each re-deriving a slightly different and occasionally wrong version.

import { Temporal } from '@js-temporal/polyfill';
function dayBounds(date: Temporal.PlainDate, zone: string) {
  const start = date.toZonedDateTime(zone).startOfDay();
  // Half-open [start, end): use for BETWEEN queries.
  return { start, end: start.add({ days: 1 }) };
}

Half-open day rangeReturn [start, next-start) for queriesHalf-open day rangedate+zonestartOfDay+1 day[start,end)

Verification snippet

Get the Start and End of Day in a Timezone — assertionsKey cases assert correctlyAssertions that prove the edge casestartOfDay normal00:00spring-forward day01:00day length23/24/25hendnext day's start

Common pitfalls

The first pitfall is zeroing time fields on a local Date to get "midnight," which produces midnight in the host zone and makes day boundaries disagree between server and browser. Anchor the boundary in an explicit zone with startOfDay. The second is hard-coding 23:59:59.999 as the end of day, which both invites precision bugs and is wrong on days that are not 24 hours long; use the next day's startOfDay as an exclusive end instead. The third is assuming midnight always exists — on a spring-forward day it may not, so a literal 00:00 is an invalid wall time that startOfDay avoids by returning the first valid instant.

A subtler mistake is computing the day boundary in the wrong zone for the feature — using UTC for a user-facing "today" that should follow the user's local midnight, so the day appears to roll over at the wrong time for users far from UTC. Pass the reporting zone explicitly. Finally, mixing an inclusive lower bound with an inclusive upper bound double-counts records on the shared midnight; keep the range half-open so adjacent days tile exactly and every record falls in exactly one day.

Start & End of Day pitfallsCommon mistakes and their fixesWrongRightset time to 00:00:00invalid at DST gapzdt.startOfDay()real first instantend = 23:59:59.999misses a secondend = start + 1 dayexact half-open

Frequently Asked Questions

How do I get the start of the day in a specific time zone?

Build a ZonedDateTime in that zone and call startOfDay(). It returns the first valid instant of the calendar day, which is 00:00 on normal days but can be 01:00 on a spring-forward day where midnight does not exist.

How should I represent the end of the day for a query?

Use the next day's startOfDay() as an exclusive upper bound and query the half-open range [start, end). That avoids the classic 23:59:59.999 approach, which can miss events in the final fraction of a second.

How do I get the start and end of a day in a specific time zone?

Build a ZonedDateTime in the zone and call startOfDay() for the first instant, then add one day for the exclusive end: start.add({ days: 1 }). This gives a half-open [start, nextStart) range you query with >= start AND < end. startOfDay returns the first valid instant of the civil day in that zone — normally midnight, but the post-transition time on spring-forward days when midnight does not exist.

Why not just set the time to 00:00:00 for start of day?

Because that yields midnight in the host machine's zone, not the zone you care about, so day boundaries disagree between a UTC server and a user's browser. And on a daylight-saving spring-forward day midnight may not exist at all — the clock jumps from 23:59 to 01:00 — making a literal 00:00 an invalid wall time. startOfDay() on a ZonedDateTime anchors the boundary in an explicit zone and returns the first valid instant, handling both problems.

Why use the next day's start instead of 23:59:59.999 as the end of day?

Because a half-open end at the next day's startOfDay is exact and precision-independent, whereas 23:59:59.999 invites questions about how many nines to use and is wrong on days that are 23 or 25 hours long. The half-open [start, nextStart) range also means a record at exactly midnight belongs to the correct single day, with no double-counting on the boundary that an inclusive end would cause.