Skip to content
Chapter 83Lesson 2

Calendar days, not midnight instants

Storing calendar days like a due date or a birthday with Postgres date and Temporal.PlainDate, so a value that means the same day everywhere never gets pinned to a single timezone's instant.

Last lesson you ran a grammar test over a handful of columns. Two of them, the day an invoice is due and a user’s birthday, went into a bucket labelled “calendar day, next lesson,” and you moved on. This is that lesson, and it pays off the placeholder you were left holding.

Here is the concrete case. An invoice has a dueDate, the day the customer agreed to pay by. The naive shape for that column is the one you just spent a whole lesson building: a timestamptz set to midnight UTC. It passes every test you write, because your machine and your Vercel functions all run at or near UTC. Then a customer in Sydney opens an invoice you stamped for May 15 and reads it as due May 14, a day early, and you can’t reproduce it. The fix isn’t a patch on that column. It’s a different storage triple that exists specifically for days, and this lesson installs it.

You already own the machinery: the customType pattern, the seam in lib/temporal.ts, ISO 8601 on the wire. So the only genuinely new ideas are when a column is a day and why the day pair is structurally safer. The good news up front is that this pair is simpler than the instant one. There is no space-to-T repair and no native-Postgres-text trap. The hard codec is behind you.

Before any code, here is the one distinction the whole lesson hangs on. Given a date-bearing column, you are answering one of exactly two questions, and the answer locks everything downstream.

Calendar-day values go in a date column with Temporal.PlainDate . Instant values go in timestamptz with Temporal.Instant. They are not interchangeable, and the runtime will not catch a swap. No exception throws and no type error fires when you put a day where an instant belongs. It simply produces wrong answers for some of your users and right answers for the rest, which is the worst failure shape there is.

Here is the grammar test from last lesson, sharpened: if “May 15” is the answer no matter where the user is standing, it’s a calendar day; if the answer is “this exact second,” it’s an instant. A dueDate is May 15 in Sydney and in Los Angeles at once. So is a birthDate, a subscriptionStartDate, an effectiveDate, a holidayDate. None of those has a single moment attached, and that’s not a formatting detail. It’s a difference in kind.

Stay on that point for a second, because it makes the rest obvious. Take a birthday: May 5, 1990. Ask “what instant was that?” and there is no honest answer. It was May 5 in Tokyo and May 5 in Los Angeles simultaneously, even though those places are sixteen hours apart in real time. The calendar flipped to the 5th at a different moment in each place, and the birthday is true across all of them. There is no point on the world’s timeline you can name and call the birthday. A calendar day isn’t a moment you’re rounding off to the nearest day. It’s a different thing: a label on a square of the calendar that every timezone shares. An instant is a pin in real time. Trying to store one as the other is a category error, and the date and timestamptz split is the type system refusing to let you blur them.

So the move, every time, is to ask the distinction first and let it pick the column, the Temporal type, and the codec as a locked set. Walk it once explicitly, in the order an experienced engineer actually asks the questions, with the trap path included, because the trap is where the real mistakes happen.

Classifying a date-bearing column

The third branch is the one to internalize. Reaching for midnight to cram a date into a timestamp feels like handling a date, but the midnight is fiction: a value you invented because the column demanded a time you didn’t have. The next two sections show what happens when that fiction ships.

You know how to read a Postgres type doc now, so this is quick, and it’s the calm inverse of last lesson’s headline trap. Postgres date is four bytes: a year, a month, a day. No time, no timezone, nothing the session can reinterpret. 2026-05-15 is the same text in every connection, on every machine, forever.

That’s the whole contrast with timestamptz. Last lesson, the same stored row printed as two different strings depending on the session’s TimeZone: the bytes were fixed but the text disagreed by eight hours, and that disagreement was the entire source of trouble. A date has nothing to convert on the way in or out, so there’s nothing for two sessions to disagree about. Here’s the same experiment that broke for timestamptz, run against a date:

Same row, two session zones
SET TIME ZONE 'UTC';
SELECT due_date::text FROM invoices WHERE id = '...';
-- → 2026-05-15
SET TIME ZONE 'Australia/Sydney';
SELECT due_date::text FROM invoices WHERE id = '...';
-- → 2026-05-15

Same text, both sessions. Nothing moved, because a date carries no offset to apply. That structural calm is exactly why a calendar day belongs in this type: there is no rendering decision baked into storage, so there’s no rendering decision that can come out wrong.

On the wire, a date travels as an ISO 8601 calendar date, '2026-05-15', with no T, no Z, and no offset. That’s the same ISO 8601 standard the instant used, but a narrower slice of it. The instant’s form was 2026-03-09T07:47:00.038Z, a full date-time pinned to UTC; the date’s form stops at the day. Same alphabet, shorter word.

Now the codec itself. Last lesson’s read seam needed a repair on the way in: Postgres hands back its own native rendering, not the canonical ISO 8601 carrier, so fromDriver had to normalize the hours-only +00 offset up to the ±HH:mm shape (+00:00) a strict parser accepts, and swap the cosmetic space for a T. The date type has no such problem. Postgres hands back '2026-05-15', Temporal.PlainDate.from() accepts that string exactly as-is, and PlainDate.prototype.toString() produces exactly that shape going the other way. Both directions are one clean line. This is the simpler sibling, and you can see how much simpler by putting the two codecs next to each other.

export const instantColumn = customType<{
data: Temporal.Instant;
driverData: string;
}>({
dataType: () => 'timestamp (3) with time zone',
toDriver: (value) => value.toString(),
fromDriver: (value) =>
Temporal.Instant.from(
value.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'),
),
});

The read seam needs a repair. Postgres’s native timestamp text isn’t a canonical ISO 8601 carrier: its offset is hours-only (+00, not +00:00). So fromDriver normalizes the offset and swaps the cosmetic space for a T before Temporal.Instant.from will reliably accept it. The highlighted line is the load-bearing one, and it’s the only reason the instant codec is more than two trivial functions.

It lives beside instantColumn in lib/temporal.ts, the same seam file in the same shape, so when the project swaps the Temporal import for native Node 26 the day pair migrates for free right alongside the instant pair. At the schema, it reads exactly like its sibling:

dueDate: dateColumn('due_date').notNull(),

Put that next to createdAt: instantColumn('created_at').notNull().defaultNow() from last lesson and the parallel is the point: you pick the column type by answering the two questions, and everything else about the call site is identical.

Why PlainDate, and not a Date or a bare string

Section titled “Why PlainDate, and not a Date or a bare string”

The column hands back a Temporal.PlainDate. It’s worth being explicit about why that’s the right domain type, because the two obvious alternatives each fail in an instructive way.

A bare ISO string, where you just store and pass '2026-05-15' around, falls over the moment you need to do anything with it. “Due in 30 days” is string surgery you’d have to hand-roll, leap years and month lengths and all. Arithmetic needs a type that understands the calendar.

A Date is the actively dangerous choice. A Date is always an instant under the hood, so the moment you construct one from '2026-05-15' it rebinds to midnight UTC. You have just dragged the exact timezone gotcha this entire lesson exists to prevent back into your domain, on the very value that was supposed to be safe from it. A calendar day stored as a Date is a midnight-UTC instant in disguise, which is the anti-pattern, not the fix.

Temporal.PlainDate is the type whose ignorance of timezones is the feature. It is immutable and knows nothing about zones or times of day, so it cannot drift across one: there’s no offset in it to apply. And it gives you exactly the calendar operations a date needs: with({ ... }) to edit a component, add({ ... }) for arithmetic, and compare(other) for sorting. You’ll use all three shortly.

The midnight-UTC anti-pattern, seen from Sydney

Section titled “The midnight-UTC anti-pattern, seen from Sydney”

Now watch the bug actually happen, because once you’ve seen why it lands on the wrong day you’ll never reach for the wrong type again.

Set the scene with the wrong column. dueDate is declared as timestamptz, “to keep our options open” goes the tempting line, and the application writes midnight UTC for a due date of May 15, storing 2026-05-15T00:00:00Z. On your machine, in UTC, this looks completely fine.

Now bring in a customer in Sydney, which runs at UTC+10. (It’s actually +11 under their summer DST, but hold it at +10 for a clean number; the off-by-one is the same either way, and one offset keeps the picture readable.) Your stored instant, 2026-05-15T00:00:00Z, is a real moment, and on Sydney’s wall clock that moment reads as 2026-05-15T10:00:00+10:00, 10 in the morning on the 15th. Fine so far. The trouble is the other direction. When that Sydney customer sets a due date of “today, the 15th,” their local midnight is 2026-05-15T00:00:00+10:00, and converted to UTC for storage that becomes 2026-05-14T14:00:00Z. You stored the 14th. Read it back, format it naively, and the invoice says due May 14, a day before the date the customer chose. The day boundary fell in a different place for them than it did for you, because midnight is not a single instant. It happens at a different moment in every zone.

UTC time axis →
May 14 (UTC)
May 15 (UTC)
2026-05-15T00:00:00Z
14:00Z
UTC / London your machine
May 15, 00:00
Sydney UTC+10
May 15, 10:00
May 15, 00:00 local stored as 2026-05-14T14:00Z → reads back as May 14
Midnight on the calendar is a different instant in every timezone, which is why a calendar day is not an instant.

The same wrong type produces the same wrong day on three different surfaces, which is how you’ll recognize it in a codebase:

  • A query like WHERE due_date = '2026-05-15' quietly misses rows, because the stored value isn’t really the 15th for everyone.
  • The Sydney customer sees the invoice “due yesterday,” and opens a support ticket you can’t reproduce.
  • A report that groups invoices by due date files half your user base under the wrong day, and every downstream number drifts.

Here is the takeaway, and it’s worth holding onto as the shape of the whole fix. The cure is not “remember to be careful about midnight.” Careful is a habit, and habits get forgotten under deadline. The cure is structural, a matched pair of type guards: the date column rejects the time of day, since there is nowhere to put a midnight, and the Temporal.PlainDate rejects the timezone, since there is nothing to offset. With the right triple, the bug isn’t avoided, it’s impossible to express. Neither guard depends on you remembering anything; the types simply won’t hold the bad value. That’s why picking the right type is the fix and “writing careful code” is not.

Doing date math without leaving the calendar

Section titled “Doing date math without leaving the calendar”

A dueDate doesn’t just sit in a column; you compute with it. “Net 30.” “First of next month.” “Is this overdue?” Temporal.PlainDate covers each of these in one call, and every one of them stays on the calendar: a PlainDate operation returns a new PlainDate, never an instant, so there’s no door for a timezone to sneak through. What follows is a survey of the four operations a due date actually asks for; the full arithmetic surface gets a lesson of its own later.

“Due in 30 days” is add:

const net30 = dueDate.add({ days: 30 });

It returns a new PlainDate thirty days on, and dueDate itself is untouched, because the type is immutable. Note the shape: { days: 30 }, a named component, not a bare 30. The unit is always spelled out.

“Due in one month” is the same call with a different unit, and it carries the single arithmetic subtlety worth teaching here, because billing dates hit it constantly: month-end clamping.

const nextMonth = dueDate.add({ months: 1 });

What is “one month after January 31”? There is no February 31. By default, Temporal clamps to the last valid day of the target month: January 31 plus one month is February 28 (or the 29th in a leap year). It does not throw, and it does not roll over into March 3. That default has a name, the overflow option, set to 'constrain'. Most of the time clamping is what you want and you never think about it. When you’d rather have “January 31 plus one month” fail loudly than silently land on the 28th, because in your billing rules a clamp would be a real error, you opt out with overflow: 'reject', and Temporal throws instead of guessing.

“First of next month” composes with and add:

const firstOfNextMonth = dueDate.with({ day: 1 }).add({ months: 1 });

with is a component-level edit: it returns a new date with the day set to 1 and everything else unchanged. Then add walks forward a month. Read it left to right: snap to the first of this month, then step to the first of next.

And comparison, for sorting and for booleans:

const byDueDate = invoices.toSorted((a, b) =>
Temporal.PlainDate.compare(a.dueDate, b.dueDate),
);
const isOverdue = today.after(dueDate);

Temporal.PlainDate.compare(a, b) returns -1, 0, or 1, exactly the shape toSorted wants. For a plain yes/no, the instance methods a.equals(b), a.before(b), and a.after(b) read more directly (here today is the PlainDate for the current day in the user’s zone), and the same static compare works too when you’d rather check the sign yourself.

Pull back and notice the thread running through all four: every one of these operations only accepts a PlainDate and only returns a PlainDate. You cannot accidentally do instant-math on one, because there’s no timezone to supply and no time-of-day input it will take. The type guard from the last section isn’t just protecting storage; it follows the value through every calculation. Now fill in the calls yourself, and notice that the wrong option in each blank is always a piece of instant machinery that has no business on a calendar day.

The two domains sit side by side here on purpose: due is a Temporal.PlainDate (a calendar day, with no time and no zone) and issuedAt is a Temporal.Instant (the exact moment the invoice was issued). The three functions stay entirely on the calendar; issuedAt is present only as the contrast, and the decoy options are the operations you’d reach for if you mistook a calendar day for an instant.

Fill each blank with the calendar-day-correct shape. Every decoy is instant machinery — a timezone, a clock, or an epoch — that a Temporal.PlainDate has no place for. Pick the right option from each dropdown, then press Check.

const issuedAt = Temporal.Instant.from('2026-05-15T09:30:00Z'); // the contrast: an Instant, not used below
// "Net 30": the due date, thirty days on. Returns a new PlainDate.
function netThirty(due: Temporal.PlainDate): Temporal.PlainDate {
return due.___;
}
// "First of next month": snap to day 1, then step forward one month.
function firstOfNextMonth(due: Temporal.PlainDate): Temporal.PlainDate {
return due.___.___;
}
// Is the invoice overdue? True when 'today' is strictly after 'due'.
function isOverdue(due: Temporal.PlainDate, today: Temporal.PlainDate): boolean {
return ___ > 0;
}

Two of those blanks are the same month-end story the prose just walked. netThirty adds days, so it never clamps: January 31 plus 30 days counts straight through February into March 2. firstOfNextMonth adds a month, but only after with({ day: 1 }) has already moved off the 31st, so it lands cleanly on the 1st with nothing to clamp. The clamp itself is the bare case the prose named: Temporal.PlainDate.from('2026-01-31').add({ months: 1 }) is February 28, because there is no February 31 and the default overflow: 'constrain' snaps to the last valid day. Every correct pick stays on PlainDate; every decoy drags in a zone, a clock, or an epoch, the exact instant machinery a calendar day refuses.

The wire and the boundary: no time component allowed

Section titled “The wire and the boundary: no time component allowed”

A dueDate arrives over HTTP the same way every value does, as a string in a request body, which means the boundary is exactly where a midnight-UTC timestamp would try to sneak in disguised as a date. Lock the wire shape and the bad value can’t get past the door.

Inbound, the body carries the ISO 8601 calendar date string, and Zod validates the shape with z.iso.date(), the same top-level ISO builder discipline you used for forms back in the validation chapter, so there’s nothing to relearn. It accepts YYYY-MM-DD and nothing else: no T, no Z, no offset.

schemas/due-date.ts
const updateDueDateSchema = z.object({
dueDate: z.iso.date(),
});
updateDueDateSchema.safeParse({ dueDate: '2026-05-15' }); // ok
updateDueDateSchema.safeParse({ dueDate: '2026-05-15T00:00:00Z' }); // fails

Past validation you have a choice with no wrong answer: transform the validated string into a PlainDate if your domain code wants the type, or hand the string straight through dateColumn, which parses it on write anyway. Either way the value that lands in the column is a real calendar date.

Outbound is symmetric. A PlainDate serializes through toJSON() to the same '2026-05-15' string, and it must serialize, because a Temporal.PlainDate is a class instance, and class instances don’t cross the React Server Component or JSON boundary as-is, exactly as an Instant doesn’t. Same rule as last lesson: Temporal in memory, ISO 8601 on the wire.

The most valuable thing z.iso.date() does here isn’t input hygiene; it works as a tripwire on the anti-pattern. When a value arrives as '2026-05-15T00:00:00Z' where a date was expected, the schema rejects it, and that rejection is information: some upstream code path produced a midnight-UTC string for a date field, and that code is the bug. The schema doesn’t just clean the input; it tells you the date-as-instant mistake happened somewhere behind it.

A PlainDate deliberately has no time and no zone; that’s its whole safety. But sometimes you genuinely need both: “end of business on the due date, in the customer’s timezone,” for a reminder you’re going to schedule. The point of this section is that crossing from a calendar day to a real instant is always explicit. There is no implicit “midnight” and no ambient “local.” You supply the time and the zone by hand, every time, and that explicitness is the feature.

The bridge is toZonedDateTime:

const deadline = dueDate
.toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' })
.toInstant();

The source is a Temporal.PlainDate, a bare calendar day with no time and no zone. On its own it can’t name a moment in real time, because it’s missing both pieces. The whole expression’s job is to supply them, deliberately.

const deadline = dueDate
.toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' })
.toInstant();

The two things a calendar day lacks, both spelled out at the call site. plainTime: '17:00' is the clock time you’re choosing, 5 PM. timeZone: user.timeZone is whose clock, the customer’s, read from their profile. Nothing here is assumed; if you don’t name the zone, the conversion won’t happen. That’s the guard against an accidental “midnight UTC.”

const deadline = dueDate
.toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' })
.toInstant();

The result of toZonedDateTime is a Temporal.ZonedDateTime: a day, a clock, and a zone, all carried together. .toInstant() collapses it to the Temporal.Instant you’d actually store or schedule against. A calendar day has become a fixed moment, and every input that moment depended on was named in the line above it.

1 / 1

That middle type, Temporal.ZonedDateTime , is the one that carries a day and a clock and a zone at once: the bridge between this lesson’s calendar-day pair and last lesson’s instant pair. It also knows about Daylight Saving Time, which is the entire reason a later lesson on scheduling recurring work leans on it. You don’t need that depth here. Here it’s just the vehicle that gets a PlainDate to an Instant with the zone named out loud.

The conversion runs both ways, and the reverse is the mirror you’ll reach for just as often. Going back, to ask “what calendar day was this instant, for this user?”, you attach their zone and drop the clock:

const dueDay = issuedAt.toZonedDateTimeISO(user.timeZone).toPlainDate();

An Instant gains a zone to become a ZonedDateTime, then sheds its time of day to become a PlainDate. Same principle in reverse: the timezone is always named, never ambient. (The full conversion graph between all the Temporal types is a later lesson; these two crossings are the ones this chapter actually uses.)

One last check on the skill the whole lesson is really about, telling a day from an instant, under a column name designed to fool you.

Your app has a column subscription.startedAt: the exact moment a customer’s trial began, used to compute when it ends and to order the tier-change timeline. Which storage triple is correct?

timestamptz + Temporal.Instant — it names a specific moment in real time.
date + Temporal.PlainDate — the name ends in a date-ish word and it has a calendar day in it.
timestamptz set to midnight UTC of the start day — close enough, and it keeps the options open.

Both storage pairs are now real, and they’re clearer held next to each other. A date-bearing column answers one of two questions, and the answer locks a triple (Postgres type, Temporal type, codec) plus the one-line grammar test that picks it:

| The question | Postgres | Temporal | Codec | Test | | --- | --- | --- | --- | --- | | “Which exact second?” | timestamptz | Temporal.Instant | instantColumn | this exact second | | “Which day, everywhere?” | date | Temporal.PlainDate | dateColumn | this day, everywhere |

One file, lib/temporal.ts, owns both codecs. Picking the column is picking the row of that table, and you pick the row by asking the grammar test, never by reading the column’s name, which is exactly what trips people up. Work the borderline cases, because they’re where the distinction earns its keep:

  • subscription.startedAttimestamptz. The moment a trial began; “started” sounds day-ish, but the value is an instant.
  • birthDatedate. May 5, 1990 in Tokyo and in Los Angeles at once; there is no instant that is a birthday.
  • event.scheduledFortimestamptz. A meeting starts at a specific moment, even though you’d display it as a date and time.
  • “Send this report at end of day on the 15th of every month” → neither. This isn’t a stored value at all; it’s a recurring rule. Trying to model it as a column is the mistake. A later lesson on scheduling owns it, and you’ll hand it off there rather than reaching for a type here.

That last one matters: not every date-shaped requirement is a column. A repeating schedule is a rule the platform runs, not a value you store, and recognizing the difference keeps you from inventing a column that can’t hold it.

With both pairs built, the storage-and-domain half of this chapter is done. What’s left is the edge, the question the opening Sydney story really turned on: whose timezone? A user’s timezone belongs on their profile as data, not derived per request, because if you derive it on Vercel you’ll silently format every user’s data in UTC. That’s the next lesson. After it come recurring jobs that survive a DST transition, which lean on the toZonedDateTime bridge you just met, and then the full Temporal arithmetic surface this lesson only surveyed.