Calendar days, not midnight instants
Storing calendar days like a due date or birthday with a Postgres date column and Temporal.PlainDate, so a value that means the same day everywhere is never pinned to one timezone's instant.
Last lesson you ran a grammar test over your columns and set two aside as calendar days for this lesson: an invoice’s due date and a user’s birthday. Here they are.
An invoice’s dueDate is the day the customer agreed to pay by. The naive shape is the one you just spent a lesson building: a timestamptz at midnight UTC. It passes every test, because your machine and your Vercel functions run 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. The fix isn’t a patch on that column; it’s a different storage triple, one that exists for days.
You already own the machinery: the customType pattern, the seam in lib/temporal.ts, ISO 8601 on the wire. So the only new ideas are when a column is a day and why the day pair is structurally safer. Better still, this pair is simpler than the instant one: the hard codec is behind you.
Calendar day or instant?
Section titled “Calendar day or instant?”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 nothing at runtime catches a swap. Put a day where an instant belongs and no error throws: you just get wrong answers for some users and right answers for the rest, 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, and so is a birthDate, a subscriptionStartDate, an effectiveDate, a holidayDate. None has a single moment attached. That’s a difference in kind, not formatting.
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 at once, even though the calendar flipped to the 5th sixteen hours apart in the two places. No point on the world’s timeline is the birthday. A calendar day isn’t an instant rounded to the nearest day; it’s a label on a square of the calendar that every timezone shares, while an instant is a pin in real time. The date and timestamptz split is the type system refusing to let you blur them.
So ask the distinction first and let it pick the column, the Temporal type, and the codec as a locked set. Walk it once in the order an experienced engineer asks the questions, trap path included.
The instant pair from last lesson. Reach for it for createdAt, receivedAt, expiresAt, and event.scheduledFor: anything whose answer is “this exact second.”
The calendar-day pair, the one this lesson builds. Reach for it for dueDate, birthDate, and effectiveDate: anything whose answer is “this day, everywhere.”
The borderline path lands here too. The moment you catch yourself reaching for “midnight UTC” to make a date fit a timestamp, that’s the signal you wanted a date column all along.
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.
What a date column stores
Section titled “What a date column stores”A Postgres date is the calm inverse of last lesson’s headline trap. It is four bytes: a year, a month, a day. No time, no timezone, nothing the session can reinterpret. 2026-05-15 reads as the same text in every connection.
Last lesson, the same stored timestamptz row printed as two different strings depending on the session’s TimeZone, and that disagreement was the whole problem. A date has nothing to convert on the way in or out, so two sessions have nothing to disagree about. Here is the same experiment that broke for timestamptz, run against a date:
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-15Same text in both sessions, because a date carries no offset to apply. No rendering decision is baked into storage, so none 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 is the same ISO 8601 standard the instant used, but a narrower slice of it: the instant’s 2026-03-09T07:47:00.038Z pins a full date-time to UTC, while the date stops at the day.
The date column and its codec
Section titled “The date column and its codec”Last lesson’s read seam needed a repair on the way in: Postgres hands back its own timestamp rendering, not canonical ISO 8601, so fromDriver had to normalize the hours-only +00 offset to the +00:00 shape 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 it as-is, and PlainDate.prototype.toString() produces the same shape going back. Both directions are one clean line. Put the two codecs side by side to see how much simpler this one is.
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 timestamp text isn’t canonical ISO 8601: its offset is hours-only (+00, not +00:00). So fromDriver normalizes the offset and swaps the space for a T before Temporal.Instant.from will accept it. That highlighted line is the only reason the instant codec is more than two trivial functions.
export const dateColumn = customType<{ data: Temporal.PlainDate; driverData: string;}>({ dataType: () => 'date', toDriver: (value) => value.toString(), fromDriver: (value) => Temporal.PlainDate.from(value),});No repair anywhere. Postgres hands back '2026-05-15', already valid ISO 8601, so fromDriver parses it directly, and toDriver is the same value.toString() one-liner as the instant. Parse a string, stringify a date.
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 alongside the instant pair. At the schema, it reads exactly like its sibling:
dueDate: dateColumn('due_date').notNull(),Put that next to last lesson’s createdAt: instantColumn('created_at').notNull().defaultNow(): 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. The two obvious alternatives each fail in an instructive way.
A bare ISO string, where you store and pass '2026-05-15' around, falls over the moment you need to compute with it. “Due in 30 days” becomes string surgery you 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 constructing one from '2026-05-15' rebinds it to midnight UTC. You have just dragged the exact timezone gotcha this lesson exists to prevent back onto the value that was supposed to be safe from it. A calendar day stored as a Date is a midnight-UTC instant in disguise.
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 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.
Why midnight UTC lands on the wrong day
Section titled “Why midnight UTC lands on the wrong day”Watch the bug happen. Once you see why it lands on the wrong day, you won’t reach for the wrong type again.
Start with the wrong column. dueDate is declared as timestamptz, “to keep our options open,” 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 fine.
Now add a customer in Sydney, at UTC+10. (It’s +11 under summer DST, but +10 keeps the number clean; the off-by-one is the same either way.) Your stored instant is a real moment, and on Sydney’s wall clock it reads as 2026-05-15T10:00:00+10:00, 10 in the morning on the 15th. So far, fine. The trouble is the other direction. When that customer sets a due date of “today, the 15th,” their local midnight is 2026-05-15T00:00:00+10:00, which converts to 2026-05-14T14:00:00Z for storage. 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 for you, because midnight is not a single instant: it happens at a different moment in every zone.
2026-05-14T14:00Z → reads back as
May 14 The same wrong type produces the same wrong day on three 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 an 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.
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 Temporal.PlainDate rejects the timezone, since there is nothing to offset. With the right pair, 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.
PlainDate arithmetic and comparison
Section titled “PlainDate arithmetic and comparison”A dueDate isn’t just stored; you compute with it. “Net 30.” “First of next month.” “Is this overdue?” Temporal.PlainDate answers each in one call, and every one stays on the calendar: a PlainDate operation returns a new PlainDate, never an instant, so no timezone can sneak in. Here are the four operations a due date asks for; the full arithmetic surface gets its own lesson later.
“Due in 30 days” is add:
const net30 = dueDate.add({ days: 30 });It returns a new PlainDate thirty days on, leaving dueDate untouched, since the type is immutable. Note the shape: { days: 30 }, a named unit, never a bare 30.
“Due in one month” is the same call with a different unit, and it carries the one arithmetic subtlety worth teaching here, since 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, so by default Temporal clamps to the last valid day: February 28, or the 29th in a leap year. It does not throw and does not roll over into March 3. That default is the overflow option, 'constrain', and most of the time it is exactly what you want. When a clamp would be a real error in your billing rules, pass 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 steps forward a month.
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 what toSorted wants. For a plain yes/no, the instance methods a.equals(b), a.before(b), and a.after(b) read more directly (today here is the PlainDate for the current day in the user’s zone).
Every one of these operations only accepts a PlainDate and only returns a PlainDate: there is no timezone to supply and no time-of-day it will take, so instant-math is impossible by construction. Now fill in the calls yourself. Below, due is a Temporal.PlainDate and issuedAt is a Temporal.Instant, present only as the contrast; every decoy is the operation 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;}Note where clamping does and doesn’t apply. netThirty adds days, so it never clamps: January 31 plus 30 days runs straight through February into March 2. firstOfNextMonth adds a month only after with({ day: 1 }) has moved off the 31st, so it lands on the 1st with nothing to clamp. Clamping shows up only in the bare case: '2026-01-31' plus one month is February 28, because overflow: 'constrain' snaps to the last valid day.
The HTTP boundary: a calendar date, never an instant
Section titled “The HTTP boundary: a calendar date, never an instant”A dueDate arrives over HTTP as a string in a request body, which makes the boundary exactly where a midnight-UTC timestamp would try to slip through 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 it with z.iso.date(), the same top-level ISO builder discipline you used for forms in the validation chapter. It accepts YYYY-MM-DD and nothing else: no T, no Z, no offset.
const updateDueDateSchema = z.object({ dueDate: z.iso.date(),});
updateDueDateSchema.safeParse({ dueDate: '2026-05-15' }); // okupdateDueDateSchema.safeParse({ dueDate: '2026-05-15T00:00:00Z' }); // failsPast 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 to dateColumn, which parses it on write anyway. Either way, a real calendar date lands in the column.
Outbound is symmetric. A PlainDate serializes through toJSON() back to '2026-05-15', 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’s a tripwire on the anti-pattern. When '2026-05-15T00:00:00Z' arrives where a date was expected, the rejection is information: some upstream path produced a midnight-UTC string for a date field, and that code is the bug.
When a calendar day needs a clock time
Section titled “When a calendar day needs a clock time”A PlainDate has no time and no zone. Sometimes you need both: “end of business on the due date, in the customer’s timezone,” for a reminder you’re about to schedule. 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.
The bridge is toZonedDateTime:
const deadline = dueDate .toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' }) .toInstant();A Temporal.PlainDate: a bare calendar day with no time and no zone, so on its own it can’t name a moment in real time. The rest of the expression supplies what it’s missing.
const deadline = dueDate .toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' }) .toInstant();The two missing pieces, both spelled out at the call site: plainTime: '17:00' is the clock time (5 PM), and timeZone: user.timeZone is whose clock (the customer’s). Name no zone and the conversion won’t happen, which is the guard against an accidental “midnight UTC.”
const deadline = dueDate .toZonedDateTime({ timeZone: user.timeZone, plainTime: '17:00' }) .toInstant();toZonedDateTime returns a Temporal.ZonedDateTime, carrying day, clock, and zone together. .toInstant() collapses it to the Temporal.Instant you’d store or schedule against. The calendar day is now a fixed moment, and every input it depended on was named in the line above.
That middle type, Temporal.ZonedDateTime , carries a day, a clock, and a zone at once. It also tracks Daylight Saving Time, which is why a later lesson on scheduling recurring work leans on it. 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 you’ll reach for the reverse just as often. To ask “what calendar day was this instant, for this user?”, attach their zone and drop the clock:
const dueDay = issuedAt.toZonedDateTimeISO(user.timeZone).toPlainDate();The 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.
One last check on the skill this 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.Run the grammar test, and ignore the name. Would two users in different timezones agree on the exact instant the trial began? Yes — it’s one fixed point in real time, the second the clock started ticking toward the trial’s end. That’s an instant: timestamptz + Temporal.Instant. The trap is that “started” and “date” feel calendar-shaped, but the value is a moment, not a day-everywhere. The midnight-UTC option is the anti-pattern this whole lesson exists to retire — it throws away the real start time and reintroduces the timezone bug. The discrimination is about the kind of value, never the column’s name.
Pick the type by the grammar test
Section titled “Pick the type by the grammar test”A date-bearing column answers one of two questions, and the answer locks a triple — Postgres type, Temporal type, codec — plus the one-line 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 a row, and you pick the row by the grammar test, never by the column’s name. Work the borderline cases, where that distinction earns its keep:
subscription.startedAt→timestamptz. The moment a trial began; “started” sounds day-ish, but the value is an instant.birthDate→date. May 5, 1990 in Tokyo and in Los Angeles at once; there is no instant that is a birthday.event.scheduledFor→timestamptz. 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; it’s a recurring rule, owned by a later lesson on scheduling. Not every date-shaped requirement is a column.
That builds both pairs. The remaining question is the one the opening Sydney story turned on: whose timezone? A user’s timezone belongs on their profile as a users.timeZone column, not derived per request — derive it on Vercel and you’ll silently format every user’s data in UTC. That’s the next lesson.
External resources
Section titled “External resources”The calendar-day type itself — its constructor, its immutability, and the with / add / compare surface a dueDate reaches for.
Month-end clamping in detail and the overflow option — exactly why Jan 31 + 1 month lands on Feb 28 and how to make it throw instead.
The authority on the date type — four bytes, year/month/day, no time and no timezone, the same text in every session.
Zach Holman's tour of why time is hard — birthdays as 'floating' calendar days, timezones that move, and why a day is not a timestamp. The same discrimination, told as a story.