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:

TypeRepresents
year_month_dayA calendar date: separate year, month and day fields
sys_daysA 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_lastThe last day of a month, e.g. 2026y / February / last
year_month_weekdayThe n-th weekday of a month, e.g. 2026y / March / Monday[1]
weekdayDay of the week
time_zone, zoned_timeAn 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_day has no + days operator, 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 months or years to sys_days. std::chrono::months is 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 with floor<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 a choose policy 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.