Skip to content

VolaDynamics/vtz

Repository files navigation

vtz

vtz is a timezone library which provides unparalleled performance and a clean API for both regular applications, and for data-heavy workflows where scale or latency is a concern.

Does your database need to handle timezones correctly? Does your application need to be able to parse or format timestamps en masse? Are you doing modeling which depends on local time within a particular region?

If so, then vtz is the right library for you.

vtz is 30-60x faster at timezone conversions than the next leading competitor. It's faster at looking up offsets, converting UTC to local, converting local to UTC, parsing timestamps, formatting timestamps, and it's faster at looking up a timezone based on a name. Take a look at the performance section for a full comparison.

Most timezones are not a fixed offset from UTC. Clocks change because of daylight savings time, because of legislative change, or based on the preference of local governments. In the face of this complexity, many timezone libraries simply fall back on a binary search across the transition times.

vtz achieves its performance gains by instead using a block-based lookup table, with blocks indexable by bit shift. Blocks span a period of time tuned to fit the minimum spacing between transitions for a given zone. This strategy is extended to enable lookups for all possible input times by taking advantage of periodicities within the calendar system and tz database rules to map out-of-bounds inputs to blocks within the table.

For a more thorough explanation of how the underlying algorithm works, check out How it Works: vtz's algorithm for timezone conversions.

Getting Started

For a quick introduction on how to use the library, take a look at the vtz TLDR. Or, for an extended introduction, read vtz: A Guided Tour. Source code for these can be found in examples/src.

vtz::time_zone is designed for compatibility with the std::chrono::time_zone API, with some additional functions added for convenience.

// Locate a timezone
auto tz = vtz::locate_zone( "America/New_York" );

// Get the current timezone
auto tz_here = vtz::current_zone();

// Convert to local time
auto t = std::chrono::sys_seconds( ... );
auto local = tz->to_local( t );

// Convert back to UTC
auto t2 = tz->to_sys( local );

// Get current offset from UTC, at time t
auto offset = tz->offset( t );

// Format a timestamp. Uses strftime format specifiers
std::string t_str = tz->format( "%F %T %Z", t );

Using vtz in your project

If you use CMake, the fastest way to use vtz in your own project is with CPM:

CPMAddPackage("gh:voladynamics/vtz@1.0.0")

target_link_libraries(your_app PRIVATE vtz::vtz)

This will automatically download and configure vtz as part of your project's configuration.

Tip: When Using CPM, enable offline builds with CPM_SOURCE_CACHE

To avoid repeat downloads, and to enable offline builds, I recommend configuring the CPM_SOURCE_CACHE env variable (or set it in your CMakeLists.txt):

export CPM_SOURCE_CACHE=$HOME/.local/.cpm

This should be placed in your .bashrc or .zshrc.


If you install vtz, you can also use it with find_package:

find_package(vtz)

target_link_libraries(your_app PRIVATE vtz::vtz)

Building, Running, and Installing

To build everything (the library, tests, and benchmarks), use:

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build

To build only the library, add -DVTZ_ONLY=ON:

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DVTZ_ONLY=ON && cmake --build build

Dependencies

You do not need to install any packages on your system in order to build vtz. The library itself has only one dependency, ankerl::unordered_dense. This is a very fast hash map implementation which is used as the KV store for vtz::locate_zone( tzname ).

Because ankerl::unordered_dense is header-only, a copy of the headers has been provisioned at etc/3rd/ankerl.

If vtz is used as a sub-component of another library (eg, with add_subdirectory, or when included with CPM), vtz will not build tests; it will not build benchmarks; and it will not download anything: the build becomes hermetic.

If you do want to build tests and benchmarks, vtz will use CPM to download test- and benchmark-related dependencies for ease of use.

Installation

Install with:

cmake --install build --prefix [path]

If installed on a directory searched by CMake, vtz can be discovered with find_package.

Performance

Robust and reliable performance is a key focus of vtz.

Timezone conversions themselves are 52-67x faster than gcc's implementation of the std::chrono::time_zone, 45-63x faster than Google abseil, and 31x - 1800x(!!!) faster than date::time_zone from the the Hinnant date library.

When parsing timestamps, vtz achieves an 11x speedup over std::chrono::parse(), a 7x speedup over abseil, and it's some 16x faster than date::parse.

When formatting timestamps, vtz is 2.6 - 3.1x faster than libfmt, 2.9x faster than abseil, and 10 to 50x faster than date::format.

And for Timezone lookups based on a name (eg, vtz::locate_zone( "America/New_York" )), vtz achieves a 2.6 to 3x speedup over the nearest competing library.

This table of benchmarks has a full comparison. Benchmarks were generated from running bench_vtz on a Ryzen 9 7950X, and all libraries were compiled with -DCMAKE_BUILD_TYPE=Release on gcc 14.2.1.

vtz std absl date date/os tzdb fmt
to_local 0.761ns 39.8ns 48.1ns 1420ns 23.9ns
to_sys 0.852 ± 0.002 ns 56.6 ± 0.1 ns 38.7ns 1470 ± 20 ns 37.2 ± 0.1 ns
format 31.2ns 231ns 92.9ns 1710ns 302ns 81.7ns
format_to 24.1ns 217ns 71.9ns
parse_date 9.67ns 90.5ns 60.0ns 116ns 122ns
parse_time 12.9ns 152ns 99.4ns 208ns 212ns
locate_zone 6.95ns 29.5ns 20.6ns 22.8ns 23.6ns
locate_rand 9.85ns 49.0ns 26.4ns 40.9ns 42.6ns

Benchmark descriptions:

  • to_local measures the time to perform a UTC to Local conversion
  • to_sys measures the inverse - a Local Time to UTC conversion
  • format measures the time to format a timestamp, returning a std::string
  • format_to measures the time to format a timestamp to a buffer of char
  • parse_date measures the time to parse a date
  • parse_time measures the time to parse a timestamp
  • locate_zone measures the time to get a timezone object, based on the name
  • locate_rand measures the time to get a timezone object, with a different random input name each time

From the above benchmarks, we obtain a table measuring vtz's relative speedup:

vtz v. std vtz v. absl vtz v. date vtz v. date/os tzdb vtz v. fmt
to_local 52.3x 63.2x 1860x 31.4x
to_sys 66x - 67x 45x - 46x 1707x - 1739x ~43.6x
format 7.40x 2.98x 55.0x 9.69x 2.62x
format_to 9.02x 2.98x
parse_date 9.36x 6.20x 12.0x 12.6x
parse_time 11.8x 7.71x 16.1x 16.4x
locate_zone 4.24x 2.96x 3.28x 3.39x
locate_rand 4.98x 2.68x 4.15x 4.32x

Here,

  • std corresponds to gcc's implementation of std::time_zone in the C++ standard library
  • absl is Google Abseil,
  • date is the Hinnant date library, compiled with default settings,
  • date/os tzdb is the Hinnant date library, compiled using USE_OS_TZDB=1.
  • fmt is libfmt (it's not a timezone library, but it supports formatting for timestamps).

vtz does all of this on equal footing: vtz::parse() and vtz::format() can handle arbitrary format strings following strftime/strptime conventions with regard to formatting; format strings are parsed at runtime (not hardcoded!), and timezone conversions such as UTC-to-local with to_local() and local-to-UTC to_sys() match the C++ standard library's API conventions.

We have two separate benchmarks for the Hinnant date library because date uses wholly different implementations for date::time_zone, depending on if USE_OS_TZDB is enabled.

More information on performance discrepancies with the Hinnant date library

Hinnant's date and timezone library is significantly slower when USE_SYSTEM_TZ_DB=OFF. This is the default setting, and within the date::time_zone source code it implies USE_OS_TZDB=0.

Under this setting, the Hinnant date library directly evaluates the rules present in the tz database every time a timezone conversion is requested.

With USE_OS_TZDB=1, date::time_zone uses a much more efficient binary-search based approach. This puts it on-par with std::chrono::time_zone and absl::TimeZone (but still 30-40x slower than vtz).

However, in this mode, the Hinnant date library does NOT support loading from the tz database directly. It only supports loading from compiled tzif files, such as those present at /usr/share/zoneinfo/.

This causes two sets of problems:

  1. It means that anyone attempting to run an application on an older system with out-of-date zoneinfo is out of luck, and must resort to the slower implementation.
  2. Anyone running on Windows in particular must also resort to the slower implementation - there are no tzif files available for use.

If you do not (1) know about the poor defaults, and (2) exist on a system with compiled tzif files, any timezone conversions end up slow-by-default.

This has been the source of significant headaches.

By comparison - vtz supports reading from either tzif files, or from the tz database itself, with no difference in performance. Users can opt-in to reading from the tz database at runtime by setting VTZ_TZDATA_PATH=/path/to/tzdata.


Benchmarks on MSVC/Windows

vtz intends to be suitable for general use, and that means being cross-platform.

On Windows, vtz is 10x-70x as fast as Abseil at timezone conversions, 550x to 1300x faster than the Hinnant date library, up to 9000x faster than Microsoft's implementation of std::chrono::time_zone, depending on the task.

vtz std absl date fmt
to_local 1.34ns 3870ns 100ns 1830ns
to_sys 2.73 ± 0.28 ns 24750 ± 2250 ns 33.2 ± 0.6 ns 1650 ± 20 ns
format 60.2ns 27600ns 183ns 3000ns 135ns
format_to 33.4ns 26900ns 100ns
parse_date 22.4ns 557ns 105ns 366ns
parse_time 27.9ns 959ns 160ns 558ns
locate_zone 15.1ns 343ns 36.8ns 52.9ns
locate_rand 34.8ns 452ns 48.3ns 81.3ns

Table measuring relative speedup in comparison to other libraries:

vtz v. std vtz v. absl vtz v. date vtz v. fmt
to_local 2880x 74.7x 1364x
to_sys 8960x - 9173x 11x - 13x 556x - 666x
format 459x 3.05x 49.9x 2.24x
format_to 805x 3.01x
parse_date 24.8x 4.67x 16.3x
parse_time 34.4x 5.75x 20.0x
locate_zone 22.7x 2.43x 3.50x
locate_rand 13.0x 1.39x 2.34x

All benchmarks ran as part of the same process; everything was compiled in Release mode. Tables were generated from etc/data/bench_2026-03-09T14.52.12-0400.json.

Hinnant date library: When compiled for Windows, the Hinnant date library is slow because Windows does not have compiled tzif files, and so USE_OS_TZDB=1 is not supported. date fails to compile on Windows if you try to enable this option. See "More information on performance discrepancies with the Hinnant date library", above, for more info.

Microsoft STL: Microsoft's std::chrono::time_zone implementation has significant performance issues that are well-documented in the STL issue tracker. Some improvements to particular functions such as locate_zone have been merged. However, it is possible that they will be unable to fix the issue in its entirety without an ABI break.

Part of the issue has been resolved by #5548, which improved the performance of locate_zone, making it 6.7 times faster (depending on the time zone name). However, the current slow speed of __std_tzdb_get_sys_info cannot be fixed before vNext.

- Commentary on issue #2842 by YexuanXiao, the STL contributor who authored MR #5548 to fix a separate performance issue with std::chrono::locate_zone()

Raw Benchmark Output - Windows/MSVC
build/bench_vtz --benchmark_out=etc/data/bench_2026-03-09T14.52.12-0400.json
/etc/bash.bashrc: line 13: CYG_SYS_BASHRC: unbound variable
2026-03-09T14:52:17-04:00
Running C:\Users\vagrant\vtz\build\bench_vtz.exe
Run on (6 X 2611 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 Unified 2048 KiB (x6)
  L3 Unified 24576 KiB (x6)
---------------------------------------------------------------------------
Benchmark                                 Time             CPU   Iterations
---------------------------------------------------------------------------
format/date                            2924 ns         2999 ns       224000
format/absl                             184 ns          183 ns      3237161
format/fmt                              135 ns          135 ns      4977778
format_to/fmt                           100 ns          100 ns      7466667
format/std                            27649 ns        27623 ns        24889
format_to/std                         27151 ns        26855 ns        20364
format/vtz                             59.9 ns         60.2 ns     11946667
format_to_nanos/vtz                    61.2 ns         60.9 ns     10000000
format_to/vtz                          34.1 ns         33.4 ns     16865882
locate_zone/date                       53.4 ns         52.9 ns     14757647
locate_rand/date                       81.5 ns         81.3 ns     11150223
locate_zone/absl                       37.1 ns         36.8 ns     18245818
locate_rand/absl                       48.3 ns         48.3 ns     14543769
locate_zone/std                         340 ns          343 ns      1592889
locate_rand/std                         451 ns          452 ns      2214665
locate_zone/vtz                        15.3 ns         15.1 ns     64000000
locate_rand/vtz                        34.3 ns         34.8 ns     27875556
parse_date/date                         367 ns          366 ns      2007040
parse_time/date                         554 ns          558 ns       896000
parse_date/absl                         106 ns          105 ns      8960000
parse_time/absl                         157 ns          160 ns      4480000
parse_date/std                          549 ns          557 ns      1600000
parse_time/std                          966 ns          959 ns       896000
parse_date/vtz                         22.1 ns         22.4 ns     31360000
parse_time/vtz                         28.0 ns         27.9 ns     28000000
to_local_with_lookup/date              1721 ns         1694 ns       295153
to_sys_latest_with_lookup/date         1889 ns         1918 ns       358400
to_sys_earliest_with_lookup/date       1664 ns         1674 ns       373333
to_local_with_lookup/absl               133 ns          130 ns      4072727
to_local_with_lookup/std               4666 ns         4743 ns       112000
to_sys_latest_with_lookup/std         25623 ns        25613 ns        28672
to_sys_earliest_with_lookup/std       25794 ns        25690 ns        26761
to_local_with_lookup/vtz               21.9 ns         22.3 ns     25221709
to_sys_latest_with_lookup/vtz          24.2 ns         24.4 ns     22400000
to_sys_earliest_with_lookup/vtz        27.8 ns         28.3 ns     29866667
to_local/date                          1857 ns         1831 ns       298667
to_sys_latest/date                     1661 ns         1632 ns       373333
to_sys_earliest/date                   1661 ns         1674 ns       448000
to_sys/date                            1660 ns         1674 ns       448000
to_local/absl                          99.8 ns          100 ns      8726261
to_sys_latest/absl                     33.0 ns         33.7 ns     21333333
to_sys_earliest/absl                   33.0 ns         32.6 ns     17230769
to_local/std                           3872 ns         3868 ns       185838
to_sys_latest/std                     22735 ns        22496 ns        29867
to_sys_earliest/std                   22918 ns        23019 ns        29867
to_sys/std                            26593 ns        26995 ns        24889
to_local/vtz                           1.35 ns         1.34 ns    512000000
to_sys_latest/vtz                      3.05 ns         3.01 ns    223004444
to_sys_earliest/vtz                    2.98 ns         2.98 ns    235789474
to_sys/vtz                             2.49 ns         2.45 ns    267605333
to_local_s/vtz                         1.52 ns         1.53 ns    448000000
to_sys_latest_s/vtz                    3.17 ns         3.22 ns    223004445
to_sys_earliest_s/vtz                  3.11 ns         3.14 ns    224000000
date_to_civil/vtz                      6.77 ns         6.84 ns    112000000
date_from_civil_date/vtz               3.20 ns         3.15 ns    213333333
date_from_civil_year/vtz               2.29 ns         2.30 ns    298666667

Running benchmarks

To reduce variability, benchmarks were run with the frequency governor set to performance.

sudo cpupower frequency-set --governor performance

This step is entirely optional, and does not actually appear to have an impact on the performance of vtz if frequency scaling is enabled.

Benchmarks can be run with:

build/bench_vtz --benchmark_out=path/to/output.json

vtz uses google benchmark, and any of Google Benchmark's CLI arguments can be used here.

Benchmark Arguments
$ build/bench_vtz --help
benchmark [--benchmark_list_tests={true|false}]
          [--benchmark_filter=<regex>]
          [--benchmark_min_time=`<integer>x` OR `<float>s` ]
          [--benchmark_min_warmup_time=<min_warmup_time>]
          [--benchmark_repetitions=<num_repetitions>]
          [--benchmark_dry_run={true|false}]
          [--benchmark_enable_random_interleaving={true|false}]
          [--benchmark_report_aggregates_only={true|false}]
          [--benchmark_display_aggregates_only={true|false}]
          [--benchmark_format=<console|json|csv>]
          [--benchmark_out=<filename>]
          [--benchmark_out_format=<json|console|csv>]
          [--benchmark_color={auto|true|false}]
          [--benchmark_counters_tabular={true|false}]
          [--benchmark_context=<key>=<value>,...]
          [--benchmark_time_unit={ns|us|ms|s}]
          [--v=<verbosity>]

Tables were generated with

uv run --python 3.11 etc/scripts/make_bm_table.py etc/data/bench_2026-03-06T17.50.31-0500.json

make_bm_table.py is a script that processes the benchmark data, and produces two tables showing the raw performance numbers, as well as vtz's relative performance. Settings for the script can be found in bm_table_config.toml.

In some cases, there were multiple benchmarks for a function. Eg, to_sys has to_sys_latest_(vtz|absl|hinnant|...), to_sys_earliest_(...), and to_sys_(...) (where time_zone::to_sys will throw on ambiguous/nonexistent local times). In this case, the measurement table contains an error bar.

Only local times which uniquely map to a UTC timestamp were tested for the variant of to_sys which throws. If exceptions are a source of concern for your application, I recommend disambiguating by invoking to_sys with vtz::choose::latest or vtz::choose::earliest. This variant will not throw.

If you have uv and just installed, then you can use just run_bench to run benchmarks and generate a set of markdown tables comparing vtz versus other timezone libraries for your system.

just run_bench

Note that some compilers still lack an implementation for std::chrono::time_zone. If your standard library does not support std::chrono::time_zone, then chrono will be excluded from the benchmarks.


Raw Benchmark Output
build/bench_vtz --benchmark_out=etc/data/bench_2026-03-06T17:50:31-0500.json
2026-03-06T17:50:33-05:00
Running build/bench_vtz
Run on (32 X 3200.99 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x16)
  L1 Instruction 32 KiB (x16)
  L2 Unified 1024 KiB (x16)
  L3 Unified 32768 KiB (x2)
Load Average: 0.59, 0.55, 0.42
-----------------------------------------------------------------------------------
Benchmark                                         Time             CPU   Iterations
-----------------------------------------------------------------------------------
to_local/date_os_tzdb                          23.9 ns         23.9 ns     29388372
to_sys_latest/date_os_tzdb                     37.1 ns         37.1 ns     18891358
to_sys_earliest/date_os_tzdb                   37.2 ns         37.2 ns     18787312
to_sys/date_os_tzdb                            37.1 ns         37.1 ns     18880559
to_local_with_lookup/date_os_tzdb              47.7 ns         47.7 ns     14714235
to_sys_latest_with_lookup/date_os_tzdb         62.0 ns         61.9 ns     11328725
to_sys_earliest_with_lookup/date_os_tzdb       61.4 ns         61.4 ns     11412794
locate_zone/date_os_tzdb                       23.6 ns         23.6 ns     29688322
locate_rand/date_os_tzdb                       42.6 ns         42.6 ns     16449088
format/date_os_tzdb                             302 ns          302 ns      2318508
parse_date/date_os_tzdb                         122 ns          122 ns      5753104
parse_time/date_os_tzdb                         212 ns          212 ns      3305927
format/date                                    1714 ns         1713 ns       408822
format/absl                                    93.0 ns         92.9 ns      7545588
format/fmt                                     81.7 ns         81.7 ns      8574125
format_to/fmt                                  72.0 ns         71.9 ns      9717687
format/std                                      231 ns          231 ns      3046709
format_to/std                                   217 ns          217 ns      3223866
format/vtz                                     31.2 ns         31.2 ns     22459439
format_to_nanos/vtz                            29.8 ns         29.8 ns     23488226
format_to/vtz                                  24.1 ns         24.1 ns     29659826
locate_zone/date                               22.8 ns         22.8 ns     30656336
locate_rand/date                               40.9 ns         40.9 ns     17118527
locate_zone/absl                               20.6 ns         20.6 ns     33913412
locate_rand/absl                               26.4 ns         26.4 ns     26466602
locate_zone/std                                29.5 ns         29.5 ns     23745008
locate_rand/std                                49.1 ns         49.0 ns     14271097
locate_zone/vtz                                6.96 ns         6.95 ns    100622653
locate_rand/vtz                                9.86 ns         9.85 ns     71321359
parse_date/date                                 116 ns          116 ns      6025695
parse_time/date                                 208 ns          208 ns      3384216
parse_date/absl                                60.0 ns         60.0 ns     11666703
parse_time/absl                                99.5 ns         99.4 ns      7046045
parse_date/std                                 90.6 ns         90.5 ns      7741076
parse_time/std                                  153 ns          152 ns      4609590
parse_date/vtz                                 9.67 ns         9.67 ns     72337420
parse_time/vtz                                 12.9 ns         12.9 ns     54283535
to_local_with_lookup/date                      1438 ns         1437 ns       490409
to_sys_latest_with_lookup/date                 1468 ns         1467 ns       476753
to_sys_earliest_with_lookup/date               1470 ns         1469 ns       478266
to_local_with_lookup/absl                      67.7 ns         67.7 ns     10323579
to_local_with_lookup/std                       71.6 ns         71.6 ns      9778601
to_sys_latest_with_lookup/std                  86.2 ns         86.2 ns      8137160
to_sys_earliest_with_lookup/std                86.7 ns         86.7 ns      8072470
to_local_with_lookup/vtz                       7.31 ns         7.31 ns     95879636
to_sys_latest_with_lookup/vtz                  11.4 ns         11.4 ns     61086801
to_sys_earliest_with_lookup/vtz                11.5 ns         11.5 ns     60619934
to_local/date                                  1416 ns         1415 ns       495671
to_sys_latest/date                             1451 ns         1450 ns       482732
to_sys_earliest/date                           1455 ns         1454 ns       482542
to_sys/date                                    1487 ns         1486 ns       471632
to_local/absl                                  48.1 ns         48.1 ns     14562797
to_sys_latest/absl                             38.7 ns         38.7 ns     18077636
to_sys_earliest/absl                           38.7 ns         38.7 ns     18082085
to_local/std                                   39.8 ns         39.8 ns     17576314
to_sys_latest/std                              56.6 ns         56.5 ns     12397641
to_sys_earliest/std                            56.8 ns         56.8 ns     12333094
to_sys/std                                     56.6 ns         56.6 ns     12374463
to_local/vtz                                  0.761 ns        0.761 ns    918187018
to_sys_latest/vtz                             0.851 ns        0.851 ns    722354480
to_sys_earliest/vtz                           0.850 ns        0.850 ns    808667595
to_sys/vtz                                    0.855 ns        0.854 ns    814346799
to_local_s/vtz                                0.757 ns        0.756 ns    925542511
to_sys_latest_s/vtz                           0.855 ns        0.854 ns    802776174
to_sys_earliest_s/vtz                         0.854 ns        0.854 ns    772844582
date_to_civil/vtz                              6.10 ns         6.09 ns    113267236
date_from_civil_date/vtz                       2.56 ns         2.56 ns    272712802
date_from_civil_year/vtz                       2.34 ns         2.34 ns    298706392

Image showing screenshot of terminal window displaying benchmark output


How It Works: vtz's algorithm for timezone conversions

Clock transitions (such as the transition into or out of daylight savings time) have some degree of regularity, but this regularity is imperfect. Many libraries resort to some variant of a binary search across a range of transition times, or they attempt to evaluate the rules for when daylight savings time starts/ends directly (Hinnant's date library does this by default, which is why it's so much slower).

Rather than performing a binary search, vtz divides time into blocks of size $2^k$ seconds, where the block size is chosen so that there are at most 1 transition per block, independent of whether the input time is a local time, or a UTC time.

For each zone, vtz records the block size, $k$, and then indexes into the corresponding block by taking the input time and performing a bitshift: input_time >> k. Most timezones end up with a block size of k=23, corresponding to 8388608 seconds, or roughly 97 days.

Timezones such as UTC, which have no transitions at all, may use a maximally large block size so that the whole table only takes up a couple bytes.

Each block records three pieces of information:

  1. The actual time of a suitably near transition point, as a unix timestamp in seconds (8 bytes)
  2. The UTC offset prior to the transition, in seconds (4 bytes)
  3. The UTC offset after the transition, in seconds (4 bytes)

This means that each block contains 16 bytes per ~97-day period, and the entirety of the offset table for a zone that has two clock transitions per year fits into some ~30kb.

What's more, inputs that are close together in time are also likely to be close together in memory, ensuring very good cache locality (only a fraction of those 30kb will actually need to be loaded into cache, unless you're dealing with historical dates, or dates far in the future).

For obtaining the offset (or converting to local time), it is trivial to (1) look up the correct block, (2) compare against the transition point, and then (3) branchlessly select between two of the offsets.

Lookup algorithm, UTC to Local Time

This is the raw code to do a lookup of information such as the current offset from UTC:

/// if t >= tt[i], return the low 32 bits of bb[i], else obtain the hi
/// 32 bits of bb[i], where i = t >> g
///
/// Treats the result as a signed integer, and sign-extends it back to
/// 64 bits.
VTZ_INLINE constexpr i64 lookup( i64 t ) const noexcept {
    i64  i         = t >> g;
    bool select_lo = t < tt[i];
    u64  block     = bb[i];
    return i64( block << ( int( select_lo ) << 5 ) ) >> 32;
}

You can use this offset to convert a unix timestamp representing UTC time, to a timestamp representing local time. tt_utc is the table of UTC offsets.

/// For a given system time T, represented as "offsets from UTC", return
/// the timezone's current offset from UTC, in seconds.
VTZ_INLINE sec_t to_local_s( sec_t t ) const noexcept {
    // If the time is in-bounds we can use the lookup table
    if( u64( t ) + tz0_ <= tz_max_ ) VTZ_LIKELY
        return t + tt_utc.lookup( t );

    // t is _early_: use initial zone state
    if( t < 0 ) return t + tt_utc.initial();

    // use zone symmetry to compute state for equivalent time
    return t + tt_utc.lookup( get_cyclic( t, cycle_time ) );
}


Converting from local time back to UTC is more involved, but it can be done in a similarly efficient manner - the primary caveat is that you need to be able to determine if an input local time is either ambiguous or nonexistent.

When the clock falls back, you end up with a set of local times that could refer to time before or after the change. For instance, 1:30AM Eastern Time on Sunday, November 1st is ambiguous because it could refer to 1:30AM EDT (before change) or 1:30AM EST (after change).

Similarly, when a clock jumps forwards, you end up with nonexistent local times. 2:30AM Eastern Time on Sunday, March 8th is nonexistent because the clock jumps forward from 1:59:59AM to 3:00AM.

Lookup algorithm Local to UTC, with handling of ambiguous/non-existent times

vtz::time_zone::to_sys( t ) uses vtz::time_zone::to_sys_s( t ) under the hood, which takes an input time as 64-bit count of UTC seconds since the epoch.

This performs a lookup with two values:

  1. The lookup time t, which is the time for which we want to do the conversion, and
  2. The key t_key. This will be used to find a block in the lookup table containing the transition time, as well as the before and after offset.

For times within the table, t == t_key, so t is passed for both values.

For out of bounds times, get_cyclic is used to resolve an appropriate key time.

/// Converts an input local time to UTC. Throws an exception if the
/// given input time is non-existent
VTZ_INLINE sec_t to_sys_s( sec_t t ) const {
    // If the time is in-bounds, we can use the lookup table
    if( u64( t ) + tz0_ <= tz_max_ ) VTZ_LIKELY
        return _lookup_utc_or_throw( t, t );

    // t is _early_: use initial zone state
    if( t < 0 ) return t - tt_utc.initial();

    // use zone symmetry to compute state for equivalent time
    return _lookup_utc_or_throw( get_cyclic( t, cycle_time ), t );
}

_lookup_utc_or_throw works by computing two times when1 and when2 that partition local time into 3 separate periods surrounding the transition point.

  • if t_key is before when1, it must be unique (not ambiguous or nonexistent), and we know we can use the earlier offset.
  • if t_key is on or after when2, it also must be unique, and we can use the later offset.

Otherwise, times in-between are either ambiguous or nonexistent, and we throw in these cases.

// Used to implement `to_sys( t )` (throwing version)
sec_t _lookup_utc_or_throw( sec_t t_key, sec_t t ) const {
    auto ent = tt_utc.get( t_key );
    /// offset from UTC before transition time
    i64 off_pre = ent.lo();
    /// offset from UTC on or after transition time
    i64 off_post = ent.hi();
    /// If the clock falls back, then `off_post` is the earlier time
    bool falls_back = off_post < off_pre;

    auto off1 = falls_back ? off_post : off_pre; ///< Earlier offset
    auto off2 = falls_back ? off_pre : off_post; ///< Later offset

    auto when1 = ent.t + off1; ///< Local time when transition starts
    auto when2 = ent.t + off2; ///< Local time when the transition ends

    /// Time is unique and before ambiguous/nonexistent time period
    bool unique_before = t_key < when1;
    /// Time is unique and after ambiguous/nonexistent time period
    bool unique_after = when2 <= t_key;

    bool is_unique = unique_before || unique_after;
    auto off       = unique_before ? off_pre : off_post;

    // This is the happy path. The input time is unique, so we just
    // subtract the offset
    if( is_unique ) VTZ_LIKELY { return t - off; }

    // If we fall back, we repeat some period of time, so the input
    // local time must be ambiguous.
    //
    // Otherwise, when jumping forward, some period of local time is
    // non-physical. Eg, 2:30 AM EST on March 8th simply doesn't exist
    // in America/New_York, because the clock jumps from 1:59:59AM to
    // 3:00:00AM
    if( falls_back )
        throw std::runtime_error( "ambiguous local time" );
    else
        throw std::runtime_error( "nonexistent local time" );
}

For a non-throwing version, use to_sys( t, vtz::choose::{earliest,latest} ). In the event of ambiguity, choose::earliest returns the earliest possible UTC timestamp corresponding to the input local time, and choose::latest returns the latest possible timestamp corresponding to the input. nonexistent times are all treated as referring to the point in time when the transition actually occurs.



Other information (such as the timezone abbreviation at a particular time) is handled in a similarly efficient manner - the primary difference is that, rather than holding the UTC offset, the second and third elements of the block are indices into a table of timezone abbreviations.

Handling dates far in the future, or in the past

The above lookup table is very fancy, but it still needs to be finite in size. Thankfully, timezones (as specified in the tz database) observe two properties:

  1. The zone has a constant offset (and abbreviation) for times prior to the standardization of time within the given zone (eg, November 18, 1883 for the US)
  2. All timezone rules in the tz database are implemented based on the Gregorian Calendar, aka the Civil Calendar, which is the de facto international standard (and the one you are likely to be familiar with in everyday life).

The Civil Calendar follows a 400 year cycle due to the rules for adding leap days: a leap day is to be added every four years, except for years divisible by 100 which are not also divisible by 400 (so centuries are skipped, except for 1200, 1600, 2000, 2400, etc).

This has the benefit that every 400 year period contains the exact same number of days, regardless of when the period starts. That is, 146097 days per 400 years, exactly.

Conveniently, 146097 is divisible by 7. This means that weekdays also repeat on a 400 year cycle.

What does this mean? Well, it means that every year has the exact same layout as the year 400 years prior, and the year 400 years hence. If today is Wednesday on the 4th of March, then 400 years from now, it shall also be a Wednesday, March 4th. And if today were the Second Sunday of March, then it shall also be the Second Sunday of March 400 years hence.

If daylight savings time occurs within a particular zone, then the rules for when it occurs (as represented by the tz database) can either describe:

  • a particular day of the year (eg, the 153rd day of the year)
  • a particular day of a month (eg, August 3rd)
  • the n-th weekday in some month (eg, 2nd Sunday in March)
  • the last weekday in some month (eg, last Friday in April)

All of these follow a 400-year cycle.

Therefore, for times after the most recent historical rule change for the zone, a timezone's offset, its abbreviation, and all the other properties we care about also follow a 400 year cycle.

We take advantage of this symmetry in various forms throughout the implementation.

// (from implementation)

VTZ_INLINE sec_t offset_s( sec_t t ) const noexcept {
    // If the time is in-bounds, use the lookup table
    if( u64( t ) + tz0_ <= tz_max_ ) VTZ_LIKELY
        return tt_utc.lookup( t );

    // t is _early_: use initial zone state
    if( t < 0 ) return tt_utc.initial();

    // use zone symmetry to compute state for equivalent time
    return tt_utc.lookup( get_cyclic( t, cycle_time ) );
}

Acknowledgements

I would like to acknowledge Arthur David Olson, Paul Eggert, and the many volunteers and contributors to the tz database. Their efforts to standardize and document the chaos that is global timekeeping has proven invaluable for allowing innumerable other projects to properly handle timezones.

Theory and pragmatics of the tz code and data is itself an excellent resource for understanding how timezones work, and How to Read the tz Database Source Files was my primary reference when implementing vtz's parsing logic.

ankerl::unordered_dense is an excellent and performant implementation of a flat hash-map implementation, and the benchmarks carried out by Martin Leitner-Ankerl are solid and well-documented.

CPM: the CMake Package Manager is invaluable for fast prototyping, and for quickly adding dependencies to a project.

And finally, the Hinnant Date Library exists as the source of inspiration for the timezone library incorporated into the C++ standard, and despite the performance issues it exposes with regard to timezone conversions in particular, it has proven reliable. (The date side of things is otherwise very performant.)

Hinnant's work, chrono-Compatible Low-Level Date Algorithms, is also a fantastic read, and the date algorithms described there form the basis of many of the date algorithms which vtz uses for formatting, parsing, and for ingestion of the tz database itself.

The work goes out of its way to explain and document the algorithms used, and these algorithms allow conversions between Unix Time and human-readable dates to be performed without lookup tables.

About

A C++ timezone library offering unparalleled performance for date and time manipulation

Resources

License

Contributing

Stars

Watchers

Forks

Packages