C++20 Calendar and Time Zones: year_month_day, zoned_time and DST Pitfalls
Key takeaways
C++20 <chrono> can represent civil dates, do month and day arithmetic, and convert between IANA time zones. This article covers the two kinds of date arithmetic and why mixing them fails, month-end handling, weekday rules, zoned_time conversions, and what happens to local times that don't exist or happen twice around a DST change. All examples were run with MSVC 2022.
C++20 extended <chrono> from durations and clocks to civil dates and time zones. The design comes from Howard Hinnant’s date library. The core types are:
| Type | Represents |
|---|---|
year_month_day | A calendar date: separate year, month and day fields |
sys_days | A count of days since 1970-01-01 (a time_point on system_clock) |
local_days, local_time<D> | A date or time with no time zone attached (“14:00 on the wall clock”) |
year_month_day_last | The last day of a month, e.g. 2026y / February / last |
year_month_weekday | The n-th weekday of a month, e.g. 2026y / March / Monday[1] |
weekday | Day of the week |
time_zone, zoned_time | An IANA zone, and an instant paired with a zone |
The thing to understand first is the split between the field types (year_month_day and friends) and the serial type (sys_days). Most of the confusing compile errors and wrong answers come from mixing them.
A note on toolchains: every example here was compiled with MSVC 2022 (/std:c++20). GCC 10 doesn’t have these types at all. It fails with unable to find numeric literal operator 'operator""y' on the first 2026y. The calendar parts arrived in GCC 11, and time zone support came later still.
Two kinds of date arithmetic
#include <chrono>
#include <iostream>
using namespace std::chrono;
int main() {
year_month_day d = 2026y / March / 11;
std::cout << d << ' ' << d.year() << ' ' << d.month() << ' ' << d.day() << '\n';
std::cout << "+10 days: " << year_month_day{sys_days{d} + days{10}} << '\n';
std::cout << "+1 month: " << d + months{1} << '\n';
std::cout << "-1 year: " << d - years{1} << '\n';
std::cout << "diff: " << (sys_days{d} - sys_days{d - years{1}}) << '\n';
}
Output:
2026-03-11 2026 Mar 11
+10 days: 2026-03-21
+1 month: 2026-04-11
-1 year: 2025-03-11
diff: 365d
The rules:
- Days go through
sys_days.year_month_dayhas no+ daysoperator, and that is deliberate. Adding days means counting, which is what the serial representation is for. Convert, add, convert back. - Months and years go directly on
year_month_day.d + months{1}changes the month field and leaves the day alone. That’s “the same day next month”. - Don’t add
monthsoryearstosys_days.std::chrono::monthsis also a duration, the average Gregorian month of 2,629,746 seconds (about 30.44 days).sys_days{d} + months{1}compiles, but it gives a time point about 30 days, 10 hours and 29 minutes later, in a duration finer than days.year_month_day{...}then refuses to be constructed from it. If you force it through withfloor<days>, you get a date that is almost, but not quite, a month later.
That last one is the error I’ve seen most in code ported from “add 30 days” logic. Someone switches to months{1} because it looks more correct, adds it to a sys_days because that’s where the other arithmetic happens, and gets either a compile error or, after a floor, a subscription that renews at 10:29 in the morning on the wrong day. The type split is there to force you to decide whether you mean “30 days” or “next month”. Those are different things.
January 31 plus one month
Field arithmetic can produce dates that don’t exist:
year_month_day jan31 = 2026y / January / 31;
year_month_day feb31 = jan31 + months{1};
std::cout << feb31 << " ok=" << feb31.ok() << '\n';
std::cout << year_month_day{sys_days{feb31}} << '\n';
2026-02-31 is not a valid date ok=0
2026-03-03
feb31.ok() is false, and the stream operator says so in the output. Converting an invalid-but-well-formed date to sys_days is defined as day 1 of that month plus (day − 1). So February 31 rolls over to March 3. That’s one possible policy. Many billing and scheduling rules want the other one, which is to clamp to the end of the month:
year_month_day addMonthsClamped(year_month_day d, months n) {
year_month_day r = d + n; // calendrical: only the month field changes
if (!r.ok()) // Jan 31 + 1 month -> Feb 31
r = r.year() / r.month() / last; // clamp to the last valid day
return r;
}
addMonthsClamped(2026y / January / 31, months{1}) returns 2026-02-28. Neither policy is wrong. What is wrong is not choosing one, then discovering in February that half the monthly jobs ran on March 3.
A related trap: clamping is not reversible. Jan 31 → Feb 28 → Mar 28. If “monthly on the 31st” matters, keep the original day as the rule and compute each occurrence from the anchor date, not from the previous occurrence.
Other month-end and leap-year helpers:
year_month_day{2026y / February / last} // 2026-02-28
(2024y).is_leap() // true
(2100y).is_leap() // false: divisible by 100, not by 400
Weekdays and “n-th weekday of the month”
year_month_day d = 2026y / March / 11;
weekday wd{sys_days{d}};
std::cout << wd << " c=" << wd.c_encoding() << " iso=" << wd.iso_encoding() << '\n';
std::cout << year_month_day{2026y / March / Monday[1]} << '\n'; // first Monday
std::cout << year_month_day{2026y / March / Friday[last]} << '\n'; // last Friday
Wed c=3 iso=3
2026-03-02
2026-03-27
c_encoding() numbers Sunday as 0, like tm_wday. iso_encoding() numbers Monday 1 through Sunday 7, following ISO 8601. They agree for Monday to Saturday and differ only for Sunday (0 vs 7), which is exactly the kind of difference that survives testing on weekdays. If a database or API expects one convention, name the one you use in the code.
Monday[1] and Friday[last] cover rules like “first Monday” or “last Friday of the month” directly. That’s how holiday rules and DST rules themselves are written.
A next-business-day helper, skipping weekends only:
year_month_day nextBusinessDay(year_month_day date) {
sys_days d = sys_days{date} + days{1};
while (weekday{d} == Saturday || weekday{d} == Sunday)
d += days{1};
return year_month_day{d};
}
// nextBusinessDay(2026y / March / 13) -> 2026-03-16 (Friday -> Monday)
Real business-day logic also needs a holiday calendar, which is regional data, not something the standard library provides.
Ages and “years between” are not days / 365
A common shortcut is dividing a day count by 365:
int ageOn(year_month_day birth, year_month_day today) {
int age = int(today.year()) - int(birth.year());
if (today.month() < birth.month() ||
(today.month() == birth.month() && today.day() < birth.day()))
--age;
return age;
}
For a birthday on 1990-03-12, checked on 2026-03-11, ageOn returns 35, while (sys_days{today} - sys_days{birth}).count() / 365 gives 36. The 9 leap days in between push the division over the boundary a day early. Compare fields for anything a human calls “years old”. (People born on February 29 need a policy of their own. The code above treats their birthday as passed on March 1 in non-leap years.)
Time zones: local time, sys time, zoned_time
A zoned_time pairs a time_zone with an instant, and it can be built from either side:
- from a
sys_time(a UTC instant): “what does the clock on the wall say there right now?” - from a
local_time(a wall-clock reading): “when is 14:00 in Seoul, as an instant?”
// A meeting at 14:00 wall-clock time in Seoul
zoned_time seoul{"Asia/Seoul", local_days{2026y / March / 11} + 14h};
zoned_time ny{"America/New_York", seoul}; // same instant, other zone
zoned_time london{"Europe/London", seoul};
std::cout << "Seoul: " << seoul << '\n';
std::cout << "New York: " << ny << '\n';
std::cout << "London: " << london << '\n';
std::cout << "UTC: " << seoul.get_sys_time() << '\n';
Seoul: 2026-03-11 14:00:00 GMT+9
New York: 2026-03-11 01:00:00 GMT-4
London: 2026-03-11 05:00:00 GMT
UTC: 2026-03-11 05:00:00
Two things in this output are worth noticing.
New York is at UTC−4 on March 11, not −5, because US daylight saving time started on March 8, 2026 (the second Sunday in March). London hasn’t switched yet (the last Sunday in March). For about three weeks every spring, the New York–London gap is four hours instead of five. That’s the kind of thing a hard-coded offset table gets wrong.
The abbreviations are GMT+9 and GMT-4, not KST and EDT. MSVC gets its zone data from the ICU library bundled with Windows, and the abbreviation strings come from there. Other implementations print KST. Don’t parse, compare or store abbreviations. They are neither unique nor portable. Store the IANA name (Asia/Seoul) and, for instants, UTC.
On the same machine, get_tzdb().version reported 2022g. With MSVC the time zone rules are as current as the OS’s copy, not as current as your compiler. Rule changes do happen (countries abolish or move DST with little notice). If correctness for future dates matters, check the version at startup and log it.
Zone lookup failures are exceptions:
try {
[[maybe_unused]] auto* tz = locate_zone("Seoul"); // not an IANA name
} catch (const std::runtime_error&) {
// "Asia/Seoul" is the correct name
}
DST: times that don’t exist and times that happen twice
On the spring-forward day, New York clocks go from 01:59:59 straight to 03:00:00. On the fall-back day, the hour from 01:00 to 01:59 happens twice. A local_time in those windows doesn’t map to exactly one instant, and <chrono> refuses to guess:
const time_zone* nyTz = locate_zone("America/New_York");
local_time<minutes> gap = local_days{2026y / March / 8} + 2h + 30min;
// nyTz->get_info(gap).result == local_info::nonexistent
try {
zoned_time z{nyTz, gap};
} catch (const nonexistent_local_time&) {
// thrown
}
std::cout << zoned_time{nyTz, gap, choose::earliest} << '\n';
local_time<minutes> twice = local_days{2026y / November / 1} + 1h + 30min;
// nyTz->get_info(twice).result == local_info::ambiguous
std::cout << zoned_time{nyTz, twice, choose::earliest} << '\n';
std::cout << zoned_time{nyTz, twice, choose::latest} << '\n';
2026-03-08 03:00:00 GMT-4
2026-11-01 01:30:00 GMT-4
2026-11-01 01:30:00 GMT-5
The constructor without choose throws nonexistent_local_time for the gap (and ambiguous_local_time for the overlap). With choose, a nonexistent time maps to the moment of the transition, and an ambiguous one picks the first (daylight) or second (standard) occurrence. get_info(local_time) tells you in advance which case you’re in, which is useful when the input comes from a user and you’d rather ask them than pick.
This is the failure I’d plan for first in any scheduler. A job configured for “02:30 every day” in a zone with DST will, once a year, ask for a time that doesn’t exist, and once a year for a time that exists twice. Code that ignores the exception crashes one night in March. Code that swallows it silently skips a run. Code that resolves the overlap naively runs twice in November. The fix is to choose deliberately and write the choice down.
”Same time tomorrow” depends on which clock you add to
zoned_time sat{nyTz, local_days{2026y / March / 7} + 9h}; // Saturday 09:00
zoned_time plus24h{nyTz, sat.get_sys_time() + 24h};
zoned_time plus1day{nyTz, sat.get_local_time() + days{1}};
std::cout << "sys + 24h: " << plus24h << '\n';
std::cout << "local + 1 day: " << plus1day << '\n';
sys + 24h: 2026-03-08 10:00:00 GMT-4
local + 1 day: 2026-03-08 09:00:00 GMT-4
Across the spring-forward night, 24 real hours after 09:00 is 10:00 on the wall clock. Adding a day in local time keeps 09:00. Both are correct answers to different questions:
- Elapsed time (timeouts, rate limits, “expires 24 hours after issue”): add to
sys_time. - Calendar schedules (daily reports, alarms, “every day at 09:00”): add to
local_time, then convert with achoosepolicy for the gap and overlap cases above.
A practical storage rule follows from this. Store instants as UTC (sys_time). Store schedules as local wall-clock time plus the IANA zone name. Converting a recurring schedule to UTC once and storing that UTC time is the classic bug: the meeting moves by an hour twice a year.